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/shared.cjs CHANGED
@@ -134,11 +134,105 @@ var UI_FOLLOWS_FIELD = "uiFollowsDialog";
134
134
  var SIZE_MIN = -3;
135
135
  var SIZE_MAX = 6;
136
136
 
137
- /** Allowed font weight range, and the value meaning "do not touch". */
137
+ /** Allowed CODE weight range (an absolute value), and "do not touch". */
138
138
  var WEIGHT_MIN = 300;
139
139
  var WEIGHT_MAX = 600;
140
140
  var WEIGHT_UNSET = 0;
141
141
 
142
+ /**
143
+ * Allowed range of the two RELATIVE weight axes, in steps of 100-weight.
144
+ *
145
+ * The conversation and interface weight axes are offsets on each element's own
146
+ * weight, not replacements: a flat `font-weight` over the markdown subtree
147
+ * erased the heading hierarchy (measured on 0.1.7-rc.2, where every markdown
148
+ * weight lives inside its `font` shorthand — `._markdown_ h2{font:var(--dsw-font-markdown-h2)}`
149
+ * with `700` in it — and a long-handed `font-weight` with `!important` beat the
150
+ * shorthand). The delta window is the old absolute window shifted by −400, so
151
+ * `300…600` maps onto `−100…+200` one-to-one and `400` (normal) becomes 0, which
152
+ * is the value meaning "do not touch".
153
+ */
154
+ var WEIGHT_DELTA_MIN = -100;
155
+ var WEIGHT_DELTA_MAX = 200;
156
+
157
+ /**
158
+ * The widest range a font-derived slider may use, either way.
159
+ *
160
+ * The nominal window above is what the axes can always express; a family with
161
+ * faces outside it (a black 900 cut, a hairline 100) earns a wider slider — see
162
+ * `weightProfileFrom` — and these are the hard bounds that keeps that in check.
163
+ */
164
+ var WEIGHT_DELTA_FLOOR = -300;
165
+ var WEIGHT_DELTA_CEIL = 600;
166
+
167
+ /** The weight DSH gives an element that declares none of its own. */
168
+ var WEIGHT_BASE = 400;
169
+
170
+ /**
171
+ * The weight range CSS accepts at all.
172
+ *
173
+ * Named because it is easy to confuse with `WEIGHT_MIN…WEIGHT_MAX`, which is the
174
+ * window an ABSOLUTE weight from a pre-relative document occupies (300…600) and
175
+ * has nothing to do with what a font can render.
176
+ */
177
+ var WEIGHT_CSS_MIN = 100;
178
+ var WEIGHT_CSS_MAX = 900;
179
+
180
+ /**
181
+ * The weight ladder of the conversation: each group carries the weight DSH's
182
+ * own stylesheet gives those elements, so an offset lands ON TOP of it.
183
+ *
184
+ * The bases are the measured ones (`--dsw-font-markdown-h*-font-weight` 700 /
185
+ * 600, `-base-strong-font-weight` 600, `-table-head-font-weight` 500, everything
186
+ * else 400).
187
+ */
188
+ var DIALOG_WEIGHT_GROUPS = [
189
+ { base: 700, elements: "h1,h2,h3" },
190
+ { base: 600, elements: "h4,h5,h6" },
191
+ { base: 600, elements: "strong,b" },
192
+ { base: 500, elements: "th" },
193
+ {
194
+ base: 400,
195
+ elements: "p,li,td,dt,dd,blockquote,summary,em,i,a,label,small,div,span",
196
+ },
197
+ ];
198
+
199
+ /**
200
+ * The heaviest base in the conversation ladder — the layer with the least room.
201
+ *
202
+ * Derived from the ladder so the two cannot drift apart.
203
+ */
204
+ var WEIGHT_LADDER_TOP = DIALOG_WEIGHT_GROUPS.reduce(function (top, group) {
205
+ return group.base > top ? group.base : top;
206
+ }, WEIGHT_BASE);
207
+
208
+ /**
209
+ * How far above its base an offset may reach at all.
210
+ *
211
+ * Every axis applies the offset to each element's OWN weight, and the heaviest
212
+ * layer of the ladder sits at `WEIGHT_LADDER_TOP`; past `WEIGHT_CSS_MAX` minus
213
+ * that, the heaviest layer cannot move any further, so a longer range would only
214
+ * add positions that change nothing for the boldest text on screen.
215
+ *
216
+ * Staying inside it has a second, load-bearing effect: offsets then never land in
217
+ * `WEIGHT_MIN…WEIGHT_MAX`, the range an ABSOLUTE weight from a pre-relative
218
+ * document occupies (see `clampWeightDelta`). The two meanings cannot be confused,
219
+ * so a stored offset is never rescaled into a different one.
220
+ */
221
+ var WEIGHT_DELTA_UP_LIMIT = WEIGHT_CSS_MAX - WEIGHT_LADDER_TOP;
222
+
223
+ /**
224
+ * The heaviest layer a notch still has to move: bold text.
225
+ *
226
+ * The heading layer above it is deliberately left out of that requirement. A heavy
227
+ * family spaces its top faces far apart (a 700 cut next to a 900), so headings
228
+ * reach their heaviest rendering part-way up the range and would otherwise hold the
229
+ * whole control down to one wide step. Every notch must move the text you read and
230
+ * the text you emphasise; a heading that is already at its boldest face stays there.
231
+ */
232
+ var WEIGHT_LADDER_STRONG = DIALOG_WEIGHT_GROUPS.reduce(function (strong, group) {
233
+ return group.base < WEIGHT_LADDER_TOP && group.base > strong ? group.base : strong;
234
+ }, WEIGHT_BASE);
235
+
142
236
  /** Allowed line-height ratio range, in percent (100 = untouched). */
143
237
  var LINE_HEIGHT_MIN = 100;
144
238
  var LINE_HEIGHT_MAX = 160;
@@ -354,7 +448,7 @@ function normalizeField(field, value) {
354
448
  case WEIGHT_FIELD:
355
449
  case WEIGHT_DIALOG_FIELD:
356
450
  case CODE_WEIGHT_FIELD:
357
- return clampWeight(value);
451
+ return clampWeightDelta(value);
358
452
  case LINE_HEIGHT_FIELD:
359
453
  case LINE_HEIGHT_DIALOG_FIELD:
360
454
  return clampRatio(value);
@@ -426,6 +520,33 @@ function clampWeight(value) {
426
520
  return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, weight));
427
521
  }
428
522
 
523
+ /**
524
+ * Clamp one RELATIVE weight to its allowed range, migrating an absolute value.
525
+ *
526
+ * A document written before the axes became relative stores an absolute weight
527
+ * (`300…600`, `400` = normal). That window sits entirely outside the delta
528
+ * window, so the conversion is unambiguous and idempotent: a value inside
529
+ * `300…600` is old and becomes its distance from normal (`400 → 0`, `560 → +160`,
530
+ * `300 → −100`), while a value already inside `−100…200` is a delta and is left
531
+ * alone. The user's saved setup therefore keeps meaning "as bold as before"
532
+ * instead of jumping to the top of the new scale.
533
+ *
534
+ * @param {unknown} value - candidate offset, or a pre-0.2.8 absolute weight.
535
+ * @returns {number} the offset the stylesheet may use.
536
+ */
537
+ function clampWeightDelta(value) {
538
+ var delta = Number(value);
539
+ if (!isFinite(delta)) return WEIGHT_UNSET;
540
+ delta = Math.round(delta);
541
+ if (delta >= WEIGHT_MIN && delta <= WEIGHT_MAX) delta -= WEIGHT_BASE;
542
+ // The slider's own range comes from the font (see `weightProfileFrom`) and can
543
+ // reach further than the nominal one, so the hard bounds are the widest range
544
+ // any font-derived slider may use.
545
+ if (delta > WEIGHT_DELTA_CEIL) return WEIGHT_DELTA_CEIL;
546
+ if (delta < WEIGHT_DELTA_FLOOR) return WEIGHT_DELTA_FLOOR;
547
+ return delta;
548
+ }
549
+
429
550
  /**
430
551
  * Clamp one line-height ratio to the allowed percent range.
431
552
  * @param {unknown} value - candidate ratio in percent.
@@ -943,6 +1064,28 @@ var DELTA_PATTERN = /^--dsh-content-font-delta(?:-secondary)?$/;
943
1064
  /** The whole official content-size chain (size / line height / delta). */
944
1065
  var CONTENT_PATTERN = /^--dsh-content-font-(?:size|line-height|delta)(?:-secondary)?$/;
945
1066
 
1067
+ /**
1068
+ * The one property the conversation size offset is added to: the official
1069
+ * "对话字号" source, which the app writes INLINE on `body`.
1070
+ *
1071
+ * Every other markdown size is derived from it (through
1072
+ * `--dsh-content-font-delta` and the markdown shorthands), and custom properties
1073
+ * resolve where they are DECLARED — so the offset has to land here, once, or a
1074
+ * deeper override would leave the derived tokens at their body-computed values.
1075
+ */
1076
+ var CONTENT_SIZE_SOURCE = "--dsh-content-font-size";
1077
+
1078
+ /**
1079
+ * Whether one declared value is a plain literal this plugin may wrap in
1080
+ * `calc(… + Npx)`. A `var()` reference is NOT: it is either derived from a
1081
+ * property this plugin already shifted, or it belongs to another axis.
1082
+ * @param {unknown} value - a declared custom-property value.
1083
+ * @returns {boolean}
1084
+ */
1085
+ function isLiteralTokenValue(value) {
1086
+ return typeof value === "string" && value !== "" && value.indexOf("var(") === -1;
1087
+ }
1088
+
946
1089
  /**
947
1090
  * The PART-token suffixes DSH appends for single-axis consumers.
948
1091
  */
@@ -1021,6 +1164,30 @@ function isMarkdownToken(name) {
1021
1164
  return typeof name === "string" && name.indexOf("--dsw-font-markdown-") === 0 && !isCodeToken(name);
1022
1165
  }
1023
1166
 
1167
+ /**
1168
+ * The one markdown family whose sizes are LITERALS instead of following the
1169
+ * official content-size chain: the `small` series (`12px/20px`, measured on
1170
+ * 0.1.7-rc.2).
1171
+ *
1172
+ * Every other markdown size derives from `--dsh-content-font-size` through
1173
+ * `--dsh-content-font-delta`, so shifting them as well applies the offset a
1174
+ * second time. The value cannot tell the families apart: the page also carries
1175
+ * `--dsw-font-markdown-h1` with its var() already substituted
1176
+ * (`700 calc(21px + -2px)/…`), which reads exactly like a literal and made
1177
+ * H1/H2 shrink while H3 stayed right (the asymmetric report that started this).
1178
+ * The NAME is the only reliable discriminator, so this is a whitelist.
1179
+ */
1180
+ var MARKDOWN_LITERAL_PREFIX = "--dsw-font-markdown-small";
1181
+
1182
+ /**
1183
+ * Whether a token belongs to the literal (non-derived) markdown family.
1184
+ * @param {string} name - custom property name including the leading dashes.
1185
+ * @returns {boolean}
1186
+ */
1187
+ function isMarkdownLiteralToken(name) {
1188
+ return typeof name === "string" && name.indexOf(MARKDOWN_LITERAL_PREFIX) === 0;
1189
+ }
1190
+
1024
1191
  /**
1025
1192
  * Whether one NAME looks like a `font` shorthand token (a bare
1026
1193
  * `--dsw-font-…` name that is not a PART token or a family variable).
@@ -1258,8 +1425,7 @@ function codeSurfaceList() {
1258
1425
  * not free: an ancestor-walking selector costs about 0.03 s per 50-step drag on
1259
1426
  * 0.1.5-rc.3 (measured: stripping the ` *` forms from these two rules took the
1260
1427
  * drag from 0.89 s to 0.52 s of style recalculation). The weight family keeps
1261
- * the code subtree right through `--dfp-interface-weight` instead — see
1262
- * `weightRule`.
1428
+ * the code subtree right through `--dfp-wdelta` instead — see `weightRule`.
1263
1429
  *
1264
1430
  * @param {{code?: string[], terminal?: string[]}|null} [hints] - unused; kept so
1265
1431
  * the browser half can keep passing one harvest object to `buildFontCss`.
@@ -1351,16 +1517,19 @@ var WEIGHT_MARKDOWN_GUARD =
1351
1517
  "))";
1352
1518
 
1353
1519
  /**
1354
- * The elements that carry a weight of their own in DSH.
1520
+ * The elements that carry a weight of their own in the INTERFACE (the chrome).
1355
1521
  *
1356
1522
  * The weight is the one property that cannot ride inheritance alone: an element
1357
1523
  * whose own rule declares a weight ignores the value it would inherit, whatever
1358
- * that value's importance. Measured on 0.1.5-rc.3, the elements that do declare
1359
- * one are `button` / `span` / `div` in the chrome and `strong` / `th` in the
1360
- * conversation; the rest of the list is the inline markup that could plausibly
1361
- * join them. This replaces the old `body,body *` reach, which matched 1892
1362
- * elements on every recalculation — the whole point of the table is that it
1363
- * matches a fraction of that while still covering every pinned element.
1524
+ * that value's importance. Measured on 0.1.5-rc.3, the chrome's pinned elements
1525
+ * are `button` / `span` / `div`; the rest of the list is the inline markup that
1526
+ * could plausibly join them. This replaces the old `body,body *` reach, which
1527
+ * matched 1892 elements on every recalculation — the whole point of the table is
1528
+ * that it matches a fraction of that while still covering every pinned element.
1529
+ *
1530
+ * The CONVERSATION is not covered here: it has its own ladder with per-element
1531
+ * bases (`DIALOG_WEIGHT_GROUPS`), because one flat weight cannot preserve a
1532
+ * heading hierarchy.
1364
1533
  */
1365
1534
  var WEIGHT_ELEMENT_TABLE = [
1366
1535
  "button",
@@ -1512,6 +1681,196 @@ function resolveAxes(config) {
1512
1681
  * the stylesheet builder
1513
1682
  * ------------------------------------------------------------------ */
1514
1683
 
1684
+ /**
1685
+ * The steps a weight slider may use, finest first.
1686
+ *
1687
+ * A family ships a fixed set of faces and the browser rounds any requested
1688
+ * weight to the nearest one, so a slider that runs in single units spends long
1689
+ * stretches on one face. The control is therefore built from what the family can
1690
+ * actually render: its range runs from the lightest face it has to the heaviest,
1691
+ * and its notch is the family's own granularity.
1692
+ */
1693
+ var WEIGHT_STEP_CANDIDATES = [1, 5, 10, 25, 50, 100, 200, 300];
1694
+
1695
+ /** The weight grid the profile is measured on. */
1696
+ var WEIGHT_PROBE_LOW = 100;
1697
+ var WEIGHT_PROBE_HIGH = 900;
1698
+ var WEIGHT_PROBE_STEP = 10;
1699
+
1700
+ /** Round down/up to a multiple of `step`. */
1701
+ function floorTo(value, step) {
1702
+ return Math.floor(value / step) * step;
1703
+ }
1704
+ function ceilTo(value, step) {
1705
+ return Math.ceil(value / step) * step;
1706
+ }
1707
+
1708
+ /**
1709
+ * Does every position of `[min..max]` in `step` increments render differently?
1710
+ *
1711
+ * Checked at every base the offset is added to: a step that is fine for body text
1712
+ * can be a no-op one layer up, where the faces are further apart.
1713
+ * @param {(weight: number) => string|number} sample - probe.
1714
+ * @param {number[]} bases - the weights the offsets are added to.
1715
+ * @param {number} min - first offset.
1716
+ * @param {number} max - last offset.
1717
+ * @param {number} step - offset increment.
1718
+ * @returns {boolean} true when no two neighbours render the same at any base.
1719
+ */
1720
+ function everyPositionDiffers(sample, bases, min, max, step) {
1721
+ for (var index = 0; index < bases.length; index += 1) {
1722
+ var base = bases[index];
1723
+ var previous = null;
1724
+ for (var position = min; position <= max; position += step) {
1725
+ var signature = sample(base + position);
1726
+ if (previous !== null && signature === previous) return false;
1727
+ previous = signature;
1728
+ }
1729
+ }
1730
+ return true;
1731
+ }
1732
+
1733
+ /**
1734
+ * The slider a family deserves: how far it can go, and by how much.
1735
+ *
1736
+ * A font with six faces can be made lighter or heavier only as far as those faces
1737
+ * reach, and only in steps between them; a family with a continuous axis gets a
1738
+ * fine step and a wide range. Measured on a weight grid:
1739
+ * - the downward reach is the highest weight that still renders as light as the
1740
+ * family can (asking for less cannot change anything);
1741
+ * - the upward reach is the LOWEST weight at or above the layer a notch has to
1742
+ * move that already renders as heavy as the family can, so the range ends where
1743
+ * bold text stops changing instead of running past it (see `WEIGHT_DELTA_UP_LIMIT`
1744
+ * for why that ceiling is also the widest one allowed);
1745
+ * - the step is the finest candidate whose positions all differ at every base a
1746
+ * notch has to move, so no notch is a no-op for body text or bold text. A family whose faces are unevenly spaced
1747
+ * (a 700 next to a 900) may end up with one repeated position whichever uniform
1748
+ * step is used — the coarsest candidate is then the honest answer, never a crash.
1749
+ * @param {(weight: number) => string|number} sample - renders a probe at one
1750
+ * weight and returns a signature; equal signatures mean identical rendering.
1751
+ * @param {{candidates?: number[], floor?: number, ceil?: number, top?: number}}
1752
+ * [options] overrides for the candidates, the widest allowed range and the
1753
+ * heaviest base the offset is added to (the ladder's, or the axis's own).
1754
+ * @returns {{min: number, max: number, step: number}} the slider's shape; the
1755
+ * range always contains 0, which is the "leave DSH alone" position.
1756
+ */
1757
+ function weightProfileFrom(sample, options) {
1758
+ var settings = options || {};
1759
+ var candidates =
1760
+ Array.isArray(settings.candidates) && settings.candidates.length > 0
1761
+ ? settings.candidates
1762
+ : WEIGHT_STEP_CANDIDATES;
1763
+ var floor = typeof settings.floor === "number" ? settings.floor : WEIGHT_DELTA_FLOOR;
1764
+ var ceil = typeof settings.ceil === "number" ? settings.ceil : WEIGHT_DELTA_CEIL;
1765
+ var top = typeof settings.top === "number" ? settings.top : WEIGHT_LADDER_STRONG;
1766
+ var topSignature = sample(WEIGHT_PROBE_HIGH);
1767
+ var bottomSignature = sample(WEIGHT_PROBE_LOW);
1768
+ var heaviest = WEIGHT_PROBE_HIGH;
1769
+ for (var weight = WEIGHT_PROBE_LOW; weight <= WEIGHT_PROBE_HIGH; weight += WEIGHT_PROBE_STEP) {
1770
+ if (sample(weight) === topSignature) {
1771
+ heaviest = weight;
1772
+ break;
1773
+ }
1774
+ }
1775
+ var lightest = WEIGHT_PROBE_LOW;
1776
+ for (var back = WEIGHT_PROBE_HIGH; back >= WEIGHT_PROBE_LOW; back -= WEIGHT_PROBE_STEP) {
1777
+ if (sample(back) === bottomSignature) {
1778
+ lightest = back;
1779
+ break;
1780
+ }
1781
+ }
1782
+ // How far the offset can go before NO layer that must move can move any more:
1783
+ // the widest horizon among those bases, since a layer that could still change
1784
+ // must not be cut off by one that already sits at its heaviest face.
1785
+ var headroom = Math.max(0, heaviest - WEIGHT_BASE);
1786
+ if (top !== WEIGHT_BASE) {
1787
+ for (var up = top; up <= WEIGHT_PROBE_HIGH; up += WEIGHT_PROBE_STEP) {
1788
+ if (sample(up) === topSignature) {
1789
+ headroom = Math.max(headroom, up - top);
1790
+ break;
1791
+ }
1792
+ }
1793
+ }
1794
+ var reachUp = Math.min(ceil, WEIGHT_DELTA_UP_LIMIT, headroom);
1795
+ var reachDown = Math.max(floor, Math.min(0, lightest - WEIGHT_BASE));
1796
+ var bases = top === WEIGHT_BASE ? [WEIGHT_BASE] : [WEIGHT_BASE, top];
1797
+ for (var index = 0; index < candidates.length; index += 1) {
1798
+ var step = candidates[index];
1799
+ if (!(step > 0)) continue;
1800
+ var min = floorTo(reachDown, step);
1801
+ // Rounding up to the step must not push the range past the limit: the widest
1802
+ // offset has to stay out of the absolute-weight window.
1803
+ var max = Math.min(ceilTo(reachUp, step), WEIGHT_DELTA_UP_LIMIT);
1804
+ // A step wider than the range would leave only one reachable position.
1805
+ if (max > min && step > max - min) continue;
1806
+ if (everyPositionDiffers(sample, bases, min, max, step)) {
1807
+ return { min: min, max: max, step: step };
1808
+ }
1809
+ }
1810
+ var coarsest = candidates[candidates.length - 1];
1811
+ var min = floorTo(reachDown, coarsest);
1812
+ var max = Math.min(ceilTo(reachUp, coarsest), WEIGHT_DELTA_UP_LIMIT);
1813
+ // Nothing kept every position alive: the coarsest step that still fits is the
1814
+ // honest answer, never a step the control cannot reach.
1815
+ var span = max - min;
1816
+ return { min: min, max: max, step: span > 0 ? Math.min(coarsest, span) : 1 };
1817
+ }
1818
+
1819
+ /**
1820
+ * Turn a measured profile into the slider the card shows.
1821
+ *
1822
+ * The document keeps storing a weight offset (so nothing written before has to be
1823
+ * migrated), but the CONTROL counts the family's own steps: a six-face family
1824
+ * offers "-2 … +4" instead of "-200 … +400", which is what a reader can actually
1825
+ * see. `unit` is how many weight units one step is worth.
1826
+ * @param {{min: number, max: number, step: number}} profile - measured shape.
1827
+ * @param {number} value - the stored offset, in weight units.
1828
+ * @returns {{min: number, max: number, unit: number, value: number}} the slider
1829
+ * in step counts; a stored value outside the measured range widens it, so it is
1830
+ * never unreachable.
1831
+ */
1832
+ function weightStepRange(profile, value) {
1833
+ var unit = profile && profile.step > 0 ? profile.step : 1;
1834
+ var count = Math.round(Number(value) / unit);
1835
+ if (!isFinite(count)) count = 0;
1836
+ var min = Math.floor(profile.min / unit);
1837
+ var max = Math.ceil(profile.max / unit);
1838
+ if (count < min) min = count;
1839
+ if (count > max) max = count;
1840
+ return { min: min, max: max, unit: unit, value: count };
1841
+ }
1842
+
1843
+ /**
1844
+ * What a slider's local (unconfirmed) value should do when a value arrives from
1845
+ * the settings document.
1846
+ *
1847
+ * A slider keeps the value the user released until the document echoes it back;
1848
+ * dropping it immediately would show the old number for a frame (the "bounce
1849
+ * back, then settle" flicker). Two cases must NOT drop it:
1850
+ * - a confirmation for the PREVIOUS commit arriving after the user already
1851
+ * started a new drag — the document write is debounced and can land seconds
1852
+ * later (measured on 0.1.7-rc.2: 0.7–5 s), and clearing there snapped the
1853
+ * thumb back to the old value mid-drag;
1854
+ * - any unrelated outside value (another page, a reset) arriving mid-drag.
1855
+ *
1856
+ * @param {{pending: number|null, awaiting: number|null, confirmed: number}} state
1857
+ * the local value, the value a commit is waiting for (null when idle), and the
1858
+ * value the document currently reports.
1859
+ * @returns {{pending: number|null, awaiting: number|null}} the state to keep.
1860
+ */
1861
+ function reconcileSliderValue(state) {
1862
+ var pending = state.pending;
1863
+ var awaiting = state.awaiting;
1864
+ var confirmed = state.confirmed;
1865
+ if (awaiting !== null && confirmed === awaiting) {
1866
+ awaiting = null;
1867
+ if (pending === confirmed) pending = null;
1868
+ return { pending: pending, awaiting: awaiting };
1869
+ }
1870
+ if (awaiting === null && pending !== null && pending === confirmed) pending = null;
1871
+ return { pending: pending, awaiting: awaiting };
1872
+ }
1873
+
1515
1874
  /**
1516
1875
  * Whether a normalized configuration asks for any change at all.
1517
1876
  * @param {unknown} config - candidate configuration.
@@ -1537,9 +1896,9 @@ function isDormant(config) {
1537
1896
  sanitize(axis[MONO_FIELD]) === "" &&
1538
1897
  clampOffset(axis[SIZE_DIALOG_FIELD]) === 0 &&
1539
1898
  clampOffset(axis[CODE_SIZE_FIELD]) === 0 &&
1540
- clampWeight(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1541
- clampWeight(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1542
- clampWeight(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
1899
+ clampWeightDelta(axis[WEIGHT_FIELD]) === WEIGHT_UNSET &&
1900
+ clampWeightDelta(axis[WEIGHT_DIALOG_FIELD]) === WEIGHT_UNSET &&
1901
+ clampWeightDelta(axis[CODE_WEIGHT_FIELD]) === WEIGHT_UNSET &&
1543
1902
  clampRatio(axis[LINE_HEIGHT_DIALOG_FIELD]) === LINE_HEIGHT_MIN &&
1544
1903
  clampLineOffset(axis[CODE_LINE_HEIGHT_FIELD]) === 0 &&
1545
1904
  clampLigatures(axis[LIGATURES_FIELD]) === LIGATURES_DEFAULT &&
@@ -1572,8 +1931,9 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1572
1931
  var dialog = formatStack(parseStack(axis[STACK_DIALOG_FIELD]));
1573
1932
  // Normalised once: an expanded axis always carries a number, but a caller
1574
1933
  // handing in a raw object may leave the field out, and "absent" has to mean
1575
- // "unset" rather than "write the string undefined".
1576
- var dialogWeight = typeof axis[WEIGHT_DIALOG_FIELD] === "number" ? axis[WEIGHT_DIALOG_FIELD] : WEIGHT_UNSET;
1934
+ // "unset" rather than "write the string undefined". The conversation's value
1935
+ // is an OFFSET (see `clampWeightDelta`), not a weight.
1936
+ var dialogDelta = typeof axis[WEIGHT_DIALOG_FIELD] === "number" ? axis[WEIGHT_DIALOG_FIELD] : WEIGHT_UNSET;
1577
1937
  var codeSurfaces = codeSelector(hints);
1578
1938
  var dialogSurfaces = dialogSelector(hints);
1579
1939
  var declarations = [];
@@ -1610,24 +1970,86 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1610
1970
  * plus an element table for the tags DSH pins a weight on of their own (see
1611
1971
  * `WEIGHT_ELEMENT_TABLE`).
1612
1972
  *
1613
- * The value rides a custom property (`--dfp-interface-weight`, declared on
1973
+ * The offset rides a custom property (`--dfp-wdelta`, declared on
1614
1974
  * `:root,body` by the caller) rather than a literal. That is what keeps the
1615
1975
  * code subtree right without a descendant selector: a highlight `<span>`
1616
1976
  * inside a `<pre>` IS matched by this rule's table, but it resolves the
1617
- * variable its code ancestor set (see the code weight rule), so it takes the
1618
- * code weight instead of the interface one. Naming `pre *` and friends here
1977
+ * variable its code ancestor set (see the code weight rules), so it takes the
1978
+ * code offset instead of the interface one. Naming `pre *` and friends here
1619
1979
  * or in the code rules instead cost 0.03 s per selector per drag.
1620
1980
  *
1621
- * The markdown guard only rides along when the conversation has NO weight of
1622
- * its own — with one set, the class-scoped dialog rule out-ranks this rule
1623
- * inside markdown anyway.
1981
+ * The markdown guard only rides along when the conversation has NO offset of
1982
+ * its own — with one set, the class-scoped conversation ladder out-ranks this
1983
+ * rule inside markdown anyway.
1624
1984
  */
1625
1985
  function weightRule(declarations) {
1626
1986
  var base = isDark ? DARK_ATTR : "body";
1627
1987
  var selector = base + "," + base + " :where(" + WEIGHT_ELEMENT_TABLE + ")";
1628
- if (dialogWeight === WEIGHT_UNSET) selector += WEIGHT_MARKDOWN_GUARD;
1988
+ if (dialogDelta === WEIGHT_UNSET) selector += WEIGHT_MARKDOWN_GUARD;
1629
1989
  return selector + "{" + declarations + "}";
1630
1990
  }
1991
+ /**
1992
+ * The weight one element group resolves to: its own base plus the offset that
1993
+ * is in force where the element sits, clamped to the legal CSS range so an
1994
+ * extreme offset cannot leave it (a weight below 100 is invalid, above 900 is
1995
+ * ignored by the font).
1996
+ * @param {number} base - the weight DSH gives this group.
1997
+ * @returns {string} the `font-weight` value.
1998
+ */
1999
+ function ladderWeight(base) {
2000
+ return (
2001
+ "clamp(" + WEIGHT_CSS_MIN + ",calc(" + base + " + var(--dfp-wdelta,0))," + WEIGHT_CSS_MAX + ")"
2002
+ );
2003
+ }
2004
+ /**
2005
+ * The scope the conversation WEIGHT rules hang on: the cheap markdown wrapper
2006
+ * (the harvested hint when the page has one, the substring form otherwise).
2007
+ *
2008
+ * Not the family rule's `dialogSurfaces` list: that one has to reach every
2009
+ * descendant because it overrides an inherited family, while the ladder names
2010
+ * its own elements — and reusing that list would multiply its ancestor-walking
2011
+ * `… *` selectors by five groups, which is exactly the cost the element table
2012
+ * was introduced to remove. Code surfaces need no exclusion here either: the
2013
+ * code rules pin `--dfp-wdelta` on themselves, so an element inside one adds
2014
+ * the offset in force THERE (the code's), which is the value it should get.
2015
+ * @returns {string} one selector.
2016
+ */
2017
+ function weightScopeSelector() {
2018
+ return hints !== null && typeof hints === "object" && typeof hints.markdown === "string"
2019
+ ? hints.markdown
2020
+ : '[class*="_markdown_" i]';
2021
+ }
2022
+ /**
2023
+ * A rule for the conversation scope itself, so text sitting directly in the
2024
+ * container moves with the axis (and so `--dfp-wdelta` is pinned for the
2025
+ * ladder below before any child resolves it).
2026
+ * @param {string} declarations - the declarations to write.
2027
+ * @returns {string} the rule.
2028
+ */
2029
+ function weightScopeRule(declarations) {
2030
+ var scope = weightScopeSelector();
2031
+ return (isDark ? DARK_ATTR + " " : "") + scope + "{" + declarations + "}";
2032
+ }
2033
+ /**
2034
+ * The conversation ladder: one rule per group in `DIALOG_WEIGHT_GROUPS`, each
2035
+ * scoped under the conversation.
2036
+ * @param {string} surfaceList - a comma-separated selector list.
2037
+ * @returns {string[]} one rule per group.
2038
+ */
2039
+ function weightLadderRules(surfaceList) {
2040
+ var rules = [];
2041
+ var surfaces = splitSelectorList(surfaceList);
2042
+ for (var groupIndex = 0; groupIndex < DIALOG_WEIGHT_GROUPS.length; groupIndex += 1) {
2043
+ var group = DIALOG_WEIGHT_GROUPS[groupIndex];
2044
+ var scoped = [];
2045
+ for (var surfaceIndex = 0; surfaceIndex < surfaces.length; surfaceIndex += 1) {
2046
+ var one = surfaces[surfaceIndex] + " :is(" + group.elements + ")";
2047
+ scoped.push(isDark ? DARK_ATTR + " " + one : one);
2048
+ }
2049
+ rules.push(scoped.join(",") + "{font-weight:" + ladderWeight(group.base) + " !important}");
2050
+ }
2051
+ return rules;
2052
+ }
1631
2053
  /**
1632
2054
  * Re-assert the code family inside the dialog: scoped under the markdown
1633
2055
  * container, after the dialog rule and above it in specificity, so a
@@ -1685,39 +2107,35 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1685
2107
  // page-wide `font-family` on body plus the theme variable) and the weight
1686
2108
  // (below).
1687
2109
 
1688
- // ---- interface weight ----
2110
+ // ---- interface weight (a relative offset) ----
1689
2111
  // A `body{font-weight}` rule only reaches the elements that INHERIT their
1690
2112
  // weight; the chrome's `button` / `span` / `div` class rules pin their own
1691
2113
  // (measured on 0.1.5-rc.3), which is why the second reach exists. The
1692
2114
  // conversation is held apart by `weightRule`'s guard (or by the class-scoped
1693
- // dialog rule when the conversation has a weight of its own).
1694
- var interfaceWeight = typeof axis[WEIGHT_FIELD] === "number" ? axis[WEIGHT_FIELD] : WEIGHT_UNSET;
2115
+ // conversation ladder when the conversation has an offset of its own).
2116
+ //
2117
+ // The value is an OFFSET on the element's own weight, never a replacement, so
2118
+ // raising the interface weight cannot flatten anything that shipped heavier
2119
+ // than the base. The offset rides `--dfp-wdelta` so the code rules can pin it
2120
+ // back to zero (or to the code offset) on their surfaces without any
2121
+ // descendant selector.
2122
+ var interfaceDelta = typeof axis[WEIGHT_FIELD] === "number" ? axis[WEIGHT_FIELD] : WEIGHT_UNSET;
1695
2123
  var codeWeight = typeof axis[CODE_WEIGHT_FIELD] === "number" ? axis[CODE_WEIGHT_FIELD] : WEIGHT_UNSET;
1696
- if (interfaceWeight !== WEIGHT_UNSET) {
1697
- // The variable the weight rule resolves; the code rules below override it
1698
- // on their surfaces so everything inside them inherits the code weight.
2124
+ if (interfaceDelta !== WEIGHT_UNSET) {
1699
2125
  declarations.push(
1700
- (isDark ? DARK_ATTR : ":root,body") +
1701
- "{--dfp-interface-weight:" +
1702
- interfaceWeight +
1703
- "}"
1704
- );
1705
- declarations.push(
1706
- weightRule(
1707
- "font-weight:var(--dfp-interface-weight," + interfaceWeight + ") !important"
1708
- )
2126
+ (isDark ? DARK_ATTR : ":root,body") + "{--dfp-wdelta:" + interfaceDelta + "}"
1709
2127
  );
2128
+ declarations.push(weightRule("font-weight:" + ladderWeight(WEIGHT_BASE) + " !important"));
1710
2129
  if (codeWeight === WEIGHT_UNSET) {
1711
2130
  // The code rules come after this one and win on their own (class hooks by
1712
2131
  // specificity, element selectors by order), so code surfaces — which
1713
2132
  // inherit nothing but start from the body weight — are reset here.
1714
2133
  // `normal` is what DSH computes for them (the code `font` shorthands
1715
2134
  // carry no weight), so an unset code weight keeps code exactly as DSH
1716
- // shipped it. The variable is reset alongside so an element INSIDE a code
1717
- // surface (which the interface table matches) lands on `normal` too.
1718
- declarations.push(
1719
- codeRule("font-weight:normal !important;--dfp-interface-weight:normal")
1720
- );
2135
+ // shipped it. The offset is pinned to zero alongside so an element INSIDE
2136
+ // a code surface (which the interface table matches) lands on `normal`
2137
+ // too.
2138
+ declarations.push(codeRule("font-weight:normal !important;--dfp-wdelta:0"));
1721
2139
  }
1722
2140
  }
1723
2141
 
@@ -1730,17 +2148,55 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1730
2148
  }
1731
2149
  }
1732
2150
 
1733
- // ---- dialog weight (uniform inside the conversation) ----
1734
- if (dialogWeight !== WEIGHT_UNSET) {
1735
- declarations.push(dialogRule("font-weight:" + dialogWeight + " !important"));
2151
+ // ---- dialog weight (a relative offset, applied element by element) ----
2152
+ // The conversation cannot take one flat weight: DSH gives h1..h3 700,
2153
+ // h4..h6/strong 600, th 500 and the rest 400, all inside `font` shorthands, so
2154
+ // a single `font-weight: <value> !important` on the markdown scope replaced
2155
+ // every one of them with that value — setting the axis at all flattened the
2156
+ // headings (measured on 0.1.7-rc.2: `##` came out at the body weight). The
2157
+ // ladder below re-states each group's own base PLUS the offset, so the
2158
+ // hierarchy survives any offset, in both directions, and `d = 0` injects
2159
+ // nothing at all.
2160
+ if (dialogDelta === WEIGHT_UNSET && interfaceDelta !== WEIGHT_UNSET) {
2161
+ // The conversation has NO offset of its own — which is exactly why it has to
2162
+ // say so. The interface axis declares `--dfp-wdelta` on `:root,body`, so an
2163
+ // unset conversation INHERITED it: with the interface at +80 the card showed 0
2164
+ // for the conversation while the page rendered +80, and the first notch above
2165
+ // it only added 20 units — a step nobody can see (measured on 0.1.7-rc.2:
2166
+ // `delta` read 80 at the "0" position and 100 at "+1"). Pinning zero keeps
2167
+ // "0 = leave DSH alone" true no matter what the other axes are set to.
2168
+ declarations.push(weightScopeRule("--dfp-wdelta:0"));
2169
+ }
2170
+ if (dialogDelta !== WEIGHT_UNSET) {
2171
+ // Every ladder rule reads `--dfp-wdelta`, so the conversation pins it to its
2172
+ // own offset: the interface's value must not leak in, and an element inside
2173
+ // a code surface inside the conversation still resolves the code offset that
2174
+ // the code rules pin on their own surfaces. The scope itself takes the base
2175
+ // group's weight, so text sitting directly in the container moves with the
2176
+ // axis exactly as it did when the axis was absolute.
2177
+ declarations.push(
2178
+ weightScopeRule(
2179
+ "--dfp-wdelta:" + dialogDelta + ";font-weight:" + ladderWeight(WEIGHT_BASE) + " !important"
2180
+ )
2181
+ );
2182
+ var dialogLadder = weightLadderRules(weightScopeSelector());
2183
+ for (var ladderIndex = 0; ladderIndex < dialogLadder.length; ladderIndex += 1) {
2184
+ declarations.push(dialogLadder[ladderIndex]);
2185
+ }
1736
2186
  // The card's conversation preview simulates this surface, so it takes the
1737
- // same weight (and is excluded from the interface rule above).
2187
+ // same ladder (and is excluded from the interface rule above).
1738
2188
  declarations.push(
1739
2189
  (isDark ? DARK_ATTR + " " : "") +
1740
- ".dfp-previewDialog{font-weight:" +
1741
- dialogWeight +
2190
+ ".dfp-previewDialog{--dfp-wdelta:" +
2191
+ dialogDelta +
2192
+ ";font-weight:" +
2193
+ ladderWeight(WEIGHT_BASE) +
1742
2194
  " !important}"
1743
2195
  );
2196
+ var previewLadder = weightLadderRules(".dfp-previewDialog");
2197
+ for (var previewIndex = 0; previewIndex < previewLadder.length; previewIndex += 1) {
2198
+ declarations.push(previewLadder[previewIndex]);
2199
+ }
1744
2200
  }
1745
2201
 
1746
2202
  // ---- dialog size offset (additive, on the official content-size source) ----
@@ -1757,32 +2213,33 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1757
2213
  var dialogOffsetValue = axis[SIZE_DIALOG_FIELD];
1758
2214
  if (dialogOffsetValue !== 0) {
1759
2215
  var contentSources = tokenNames(baseTokens).filter(function (name) {
1760
- if (!isContentToken(name)) return false;
1761
- var base = baseTokens[name];
1762
- // The source token only: the derived ones carry var()/calc() and would
1763
- // add the offset a second time if they were shifted as well.
1764
- return (
1765
- typeof base === "string" &&
1766
- base !== "" &&
1767
- base.indexOf("var(") === -1 &&
1768
- base.indexOf("calc(") === -1
1769
- );
2216
+ // The source property, by NAME: `--dsh-content-font-size`. The derived
2217
+ // members of the chain (`-delta`, `-size-secondary`, the markdown
2218
+ // shorthands) all read it, so shifting them as well would count the offset
2219
+ // twice.
2220
+ if (name !== CONTENT_SIZE_SOURCE) return false;
2221
+ return isLiteralTokenValue(baseTokens[name]);
1770
2222
  });
1771
2223
  var contentShift = shiftTokens(contentSources, baseTokens, dialogOffsetValue);
1772
2224
  if (contentShift.length > 0) declarations.push(rootRule(contentShift.join(";")));
1773
- // The `small` series is the only markdown family with literal sizes: it
1774
- // does not derive from the chain, so its parts and shorthands shift here.
2225
+ // The `small` series is the only markdown family with literal sizes: it does
2226
+ // not derive from the chain, so its parts and shorthands shift here — and
2227
+ // ONLY it, by name. A value test cannot stand in for the name: the page also
2228
+ // carries `--dsw-font-markdown-h1` with its var() already substituted, which
2229
+ // looks like a literal and used to take the offset a second time (H1/H2
2230
+ // shrank, H3 did not).
1775
2231
  var literals = markdownTokenNames(baseTokens).filter(function (name) {
1776
- var base = baseTokens[name];
1777
- return name.indexOf("-font-size") >= 0 && typeof base === "string" && base.indexOf("var(") === -1;
2232
+ return (
2233
+ isMarkdownLiteralToken(name) &&
2234
+ name.indexOf("-font-size") >= 0 &&
2235
+ isLiteralTokenValue(baseTokens[name])
2236
+ );
1778
2237
  });
1779
2238
  var literalShift = shiftTokens(literals, baseTokens, dialogOffsetValue);
1780
2239
  if (literalShift.length > 0) declarations.push(rootRule(literalShift.join(";")));
1781
- // The shorthands whose size is a literal (the small series) must ride the
1782
- // shift too; the delta-derived shorthands flow through the chain above.
1783
2240
  var sizeShifted = [];
1784
2241
  var markdownEntries = shorthandTokens(baseTokens).filter(function (entry) {
1785
- return isMarkdownToken(entry.name) && entry.parts.size.indexOf("var(") === -1;
2242
+ return isMarkdownLiteralToken(entry.name) && isLiteralTokenValue(entry.parts.size);
1786
2243
  });
1787
2244
  for (var mIndex = 0; mIndex < markdownEntries.length; mIndex += 1) {
1788
2245
  var mEntry = markdownEntries[mIndex];
@@ -1837,12 +2294,16 @@ function buildAxisCss(axis, baseTokens, isDark, hints) {
1837
2294
  if (codeScaled.length > 0) declarations.push(rootRule(codeScaled.join(";")));
1838
2295
  }
1839
2296
  if (codeWeight !== WEIGHT_UNSET) {
1840
- // The variable rides along so the elements INSIDE a code surface — which the
1841
- // interface weight rule's element table matches — resolve the code weight
1842
- // instead of the interface one, without any `… *` selector.
2297
+ // The code axis is a relative offset too (all three weight axes share one
2298
+ // scale, so the card shows one kind of control). Pinning the offset here
2299
+ // instead of writing a flat `font-weight` keeps whatever weight a code
2300
+ // surface ships with AND is what makes the elements INSIDE a code surface —
2301
+ // which the interface table and the conversation ladder both match — resolve
2302
+ // the code offset rather than the surrounding one, without any `… *`
2303
+ // selector. `WEIGHT_BASE` is what DSH's code `font` shorthands carry.
1843
2304
  declarations.push(
1844
2305
  codeRule(
1845
- "font-weight:" + codeWeight + " !important;--dfp-interface-weight:" + codeWeight
2306
+ "font-weight:" + ladderWeight(WEIGHT_BASE) + " !important;--dfp-wdelta:" + codeWeight
1846
2307
  )
1847
2308
  );
1848
2309
  }
@@ -2397,7 +2858,27 @@ var shared = {
2397
2858
  isFontToken: isFontToken,
2398
2859
  isCodeToken: isCodeToken,
2399
2860
  isMarkdownToken: isMarkdownToken,
2861
+ isMarkdownLiteralToken: isMarkdownLiteralToken,
2862
+ MARKDOWN_LITERAL_PREFIX: MARKDOWN_LITERAL_PREFIX,
2863
+ CONTENT_SIZE_SOURCE: CONTENT_SIZE_SOURCE,
2864
+ isLiteralTokenValue: isLiteralTokenValue,
2865
+ clampWeightDelta: clampWeightDelta,
2866
+ WEIGHT_DELTA_MIN: WEIGHT_DELTA_MIN,
2867
+ WEIGHT_DELTA_MAX: WEIGHT_DELTA_MAX,
2868
+ WEIGHT_BASE: WEIGHT_BASE,
2869
+ DIALOG_WEIGHT_GROUPS: DIALOG_WEIGHT_GROUPS,
2400
2870
  isDeltaToken: isDeltaToken,
2871
+ reconcileSliderValue: reconcileSliderValue,
2872
+ WEIGHT_STEP_CANDIDATES: WEIGHT_STEP_CANDIDATES,
2873
+ WEIGHT_DELTA_FLOOR: WEIGHT_DELTA_FLOOR,
2874
+ WEIGHT_DELTA_CEIL: WEIGHT_DELTA_CEIL,
2875
+ WEIGHT_LADDER_TOP: WEIGHT_LADDER_TOP,
2876
+ WEIGHT_LADDER_STRONG: WEIGHT_LADDER_STRONG,
2877
+ WEIGHT_DELTA_UP_LIMIT: WEIGHT_DELTA_UP_LIMIT,
2878
+ WEIGHT_CSS_MIN: WEIGHT_CSS_MIN,
2879
+ WEIGHT_CSS_MAX: WEIGHT_CSS_MAX,
2880
+ weightProfileFrom: weightProfileFrom,
2881
+ weightStepRange: weightStepRange,
2401
2882
  isShorthandName: isShorthandName,
2402
2883
  isScaledToken: isScaledToken,
2403
2884
  tokenNames: tokenNames,