dsh-fonttune 0.2.2 → 0.2.3

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
@@ -161,9 +161,6 @@ var LIGATURES_DEFAULT = 0;
161
161
  var LIGATURES_ON = 1;
162
162
  var LIGATURES_OFF = 2;
163
163
 
164
- /** Marker on both injected style tags, used for scoping every rule we write. */
165
- var MARKER = "dfp";
166
-
167
164
  /** `data-plugin-css` value of the tag applying the configuration. */
168
165
  var STYLE_TAG = "dsh-fonttune";
169
166
 
@@ -196,20 +193,18 @@ var DEFAULTS = {
196
193
  };
197
194
 
198
195
  /**
199
- * The axis fields a preset snapshots: everything the card edits, excluding
200
- * the storage fields themselves.
196
+ * The axis fields a preset snapshots and the light/dark sets are compared
197
+ * over: everything the card edits, excluding the storage fields themselves.
201
198
  */
202
199
  var VALUE_FIELDS = [
203
200
  SANS_FIELD,
204
201
  STACK_DIALOG_FIELD,
205
202
  MONO_FIELD,
206
- SIZE_FIELD,
207
203
  SIZE_DIALOG_FIELD,
208
204
  CODE_SIZE_FIELD,
209
205
  WEIGHT_FIELD,
210
206
  WEIGHT_DIALOG_FIELD,
211
207
  CODE_WEIGHT_FIELD,
212
- LINE_HEIGHT_FIELD,
213
208
  LINE_HEIGHT_DIALOG_FIELD,
214
209
  CODE_LINE_HEIGHT_FIELD,
215
210
  LIGATURES_FIELD,
@@ -219,6 +214,25 @@ var VALUE_FIELDS = [
219
214
  UI_FOLLOWS_FIELD,
220
215
  ];
221
216
 
217
+ /**
218
+ * Axes that are still stored but no longer render: the interface's own size
219
+ * and line-height.
220
+ *
221
+ * DSH 0.1.5-rc.2 publishes no interface size or line-height hook at all (the
222
+ * measurement lives in `buildAxisCss`), so these two fields inject nothing.
223
+ * A document written by 0.1.x still carries them, so they keep their schema
224
+ * entries and stay part of normalization — but they are not value axes: a
225
+ * preset no longer snapshots a dead field, and a light/dark pair that differs
226
+ * only here no longer counts as two sets. The card's "reset every axis" clears
227
+ * them too, so a value nothing shows cannot hide in a document forever.
228
+ */
229
+ var RETIRED_FIELDS = [SIZE_FIELD, LINE_HEIGHT_FIELD];
230
+
231
+ /**
232
+ * Every durable axis field, live and retired: what normalization covers.
233
+ */
234
+ var DURABLE_FIELDS = VALUE_FIELDS.concat(RETIRED_FIELDS);
235
+
222
236
  /**
223
237
  * Longest accepted font stack, in characters (mirrored by the host schema).
224
238
  */
@@ -381,8 +395,8 @@ function normalizeField(field, value) {
381
395
  function normalizeConfig(value) {
382
396
  var source = value !== null && typeof value === "object" ? value : {};
383
397
  var config = {};
384
- for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
385
- var field = VALUE_FIELDS[index];
398
+ for (var index = 0; index < DURABLE_FIELDS.length; index += 1) {
399
+ var field = DURABLE_FIELDS[index];
386
400
  config[field] = normalizeField(field, source[field]);
387
401
  }
388
402
  config[PER_THEME_FIELD] = toBool(source[PER_THEME_FIELD]);
@@ -1032,17 +1046,6 @@ function isShorthandName(name) {
1032
1046
  return true;
1033
1047
  }
1034
1048
 
1035
- /**
1036
- * Whether one token (name + value) is a `font` shorthand this plugin may
1037
- * rewrite: a bare `--dsw-font-…` name whose value parses as a shorthand.
1038
- * @param {string} name - custom property name.
1039
- * @param {string} value - the token's untouched value.
1040
- * @returns {boolean}
1041
- */
1042
- function isShorthandToken(name, value) {
1043
- return isShorthandName(name) && parseShorthand(value) !== null;
1044
- }
1045
-
1046
1049
  /**
1047
1050
  * Whether a token is one this plugin reads and re-declares at all.
1048
1051
  * @param {string} name - custom property name including the leading dashes.
@@ -1071,35 +1074,6 @@ function tokenNames(baseTokens) {
1071
1074
  return names;
1072
1075
  }
1073
1076
 
1074
- /**
1075
- * The interface PART size tokens: everything scalable except the code and
1076
- * markdown families and the delta tokens (which the dialog offset shifts
1077
- * additively, never multiplicatively).
1078
- * @param {Record<string, string>} [baseTokens] - token name to value.
1079
- * @returns {string[]} the names the interface offset re-declares.
1080
- */
1081
- function bodyTokenNames(baseTokens) {
1082
- return tokenNames(baseTokens).filter(function (name) {
1083
- return (
1084
- !isCodeToken(name) &&
1085
- !isMarkdownToken(name) &&
1086
- !isDeltaToken(name) &&
1087
- !isContentToken(name)
1088
- );
1089
- });
1090
- }
1091
-
1092
- /**
1093
- * The interface line-height PART tokens (non-code, non-markdown).
1094
- * @param {Record<string, string>} [baseTokens] - token name to value.
1095
- * @returns {string[]}
1096
- */
1097
- function uiLineTokenNames(baseTokens) {
1098
- return bodyTokenNames(baseTokens).filter(function (name) {
1099
- return name.indexOf("-line-height") >= 0;
1100
- });
1101
- }
1102
-
1103
1077
  /**
1104
1078
  * The markdown (dialog) PART tokens: sizes and line heights of the markdown
1105
1079
  * family, excluding code.
@@ -1110,30 +1084,6 @@ function markdownTokenNames(baseTokens) {
1110
1084
  return tokenNames(baseTokens).filter(isMarkdownToken);
1111
1085
  }
1112
1086
 
1113
- /**
1114
- * The markdown content-size DELTA tokens present in a base map.
1115
- * @param {Record<string, string>} [baseTokens] - token name to value.
1116
- * @returns {string[]}
1117
- */
1118
- function deltaTokenNames(baseTokens) {
1119
- if (baseTokens === null || typeof baseTokens !== "object") return [];
1120
- return Object.keys(baseTokens).filter(isDeltaToken);
1121
- }
1122
-
1123
- /**
1124
- * The code PART tokens: the `font` shorthands' size/height parts, when the
1125
- * live stylesheets declare them. The shorthands themselves are handled by
1126
- * `codeShorthandTokens`, so they are excluded here to avoid double
1127
- * declarations.
1128
- * @param {Record<string, string>} [baseTokens] - token name to value.
1129
- * @returns {string[]}
1130
- */
1131
- function codeTokenNames(baseTokens) {
1132
- return tokenNames(baseTokens).filter(function (name) {
1133
- return isCodeToken(name) && !isShorthandName(name);
1134
- });
1135
- }
1136
-
1137
1087
  /**
1138
1088
  * Every `font` shorthand token in a base map that parses, sorted by name
1139
1089
  * length (families before variants, mirroring the part-token order).
@@ -1166,17 +1116,6 @@ function markdownShorthandTokens(baseTokens) {
1166
1116
  });
1167
1117
  }
1168
1118
 
1169
- /**
1170
- * The interface (non-markdown, non-code) `font` shorthand tokens.
1171
- * @param {Record<string, string>} [baseTokens] - token name to value.
1172
- * @returns {{name: string, parts: object}[]}
1173
- */
1174
- function uiShorthandTokens(baseTokens) {
1175
- return shorthandTokens(baseTokens).filter(function (entry) {
1176
- return !isMarkdownToken(entry.name) && !isCodeToken(entry.name);
1177
- });
1178
- }
1179
-
1180
1119
  /**
1181
1120
  * The code `font` shorthand tokens.
1182
1121
  * @param {Record<string, string>} [baseTokens] - token name to value.
@@ -1192,6 +1131,66 @@ function codeShorthandTokens(baseTokens) {
1192
1131
  * selectors
1193
1132
  * ------------------------------------------------------------------ */
1194
1133
 
1134
+ /**
1135
+ * Build one exclusion that keeps a surface out of a rule.
1136
+ *
1137
+ * The whole list goes into a single `:not(…)`. The long form —
1138
+ * `:not(pre):not(pre *)…` — repeats `:not(` and `)` per argument, and that
1139
+ * chain is copied into both dialog alternatives and the interface rule, so the
1140
+ * compact form saves roughly 120 bytes per copy.
1141
+ *
1142
+ * The trade is specificity: `:not(a):not(b)` SUMS its arguments while
1143
+ * `:not(a, b)` takes the most specific one. Nothing here depends on that sum.
1144
+ * Every rule below carries `!important`, which already beats the theme's
1145
+ * non-important class rules whatever the specificity; the shipped DSH 0.1.5
1146
+ * assets hold exactly one `!important` typography declaration
1147
+ * (`font-size` on inline markdown code, and no size declaration is ever
1148
+ * emitted from here); and the rules that could collide with each other are
1149
+ * held apart by these very exclusions rather than by their weight. Verified
1150
+ * against the shipped bundle and every installed plugin: no other
1151
+ * `!important` `font-weight`/`font-family` rule exists to lose to.
1152
+ *
1153
+ * @param {string[]} selectors - the selectors to exclude.
1154
+ * @returns {string} one `:not(…)` carrying the whole list.
1155
+ */
1156
+ function excludeAll(selectors) {
1157
+ return ":not(" + selectors.join(",") + ")";
1158
+ }
1159
+
1160
+ /**
1161
+ * A code surface, and its descendants: a highlight `<span>` inside a `<pre>`
1162
+ * is not a `pre`, so an exclusion has to reach through ancestry.
1163
+ */
1164
+ var CODE_SURFACE_SELECTORS = [
1165
+ "pre",
1166
+ "pre *",
1167
+ "code",
1168
+ "code *",
1169
+ "kbd",
1170
+ "kbd *",
1171
+ "samp",
1172
+ "samp *",
1173
+ "var",
1174
+ "var *",
1175
+ "tt",
1176
+ "tt *",
1177
+ "textarea",
1178
+ "textarea *",
1179
+ '[class*="code" i]',
1180
+ '[class*="code" i] *',
1181
+ '[class*="terminal" i]',
1182
+ '[class*="terminal" i] *',
1183
+ ];
1184
+
1185
+ /**
1186
+ * Everything a code surface must be excluded from a DIALOG-scoped rule: the
1187
+ * code elements themselves AND their descendants (a highlight `<span>` inside
1188
+ * a `<pre>` is not a `pre`, so the exclusion has to reach through ancestry
1189
+ * with a complex `:not()` argument — supported by the Chromium line DSH
1190
+ * targets).
1191
+ */
1192
+ var CODE_EXCLUDES = excludeAll(CODE_SURFACE_SELECTORS);
1193
+
1195
1194
  /**
1196
1195
  * What counts as code text for the code axes.
1197
1196
  *
@@ -1230,38 +1229,6 @@ var CODE_SELECTOR = [
1230
1229
  })
1231
1230
  .join(",");
1232
1231
 
1233
- /**
1234
- * Everything a code surface must be excluded from a DIALOG-scoped rule:
1235
- * the code elements themselves AND their descendants (a highlight `<span>`
1236
- * inside a `<pre>` is not a `pre`, so the exclusion has to reach through
1237
- * ancestry with complex `:not()` arguments — supported by the Chromium line
1238
- * DSH targets).
1239
- */
1240
- var CODE_EXCLUDES = [
1241
- "pre",
1242
- "pre *",
1243
- "code",
1244
- "code *",
1245
- "kbd",
1246
- "kbd *",
1247
- "samp",
1248
- "samp *",
1249
- "var",
1250
- "var *",
1251
- "tt",
1252
- "tt *",
1253
- "textarea",
1254
- "textarea *",
1255
- '[class*="code" i]',
1256
- '[class*="code" i] *',
1257
- '[class*="terminal" i]',
1258
- '[class*="terminal" i] *',
1259
- ]
1260
- .map(function (selector) {
1261
- return ":not(" + selector + ")";
1262
- })
1263
- .join("");
1264
-
1265
1232
  /**
1266
1233
  * The dialog surface: DSH renders the conversation markdown in a CSS-module
1267
1234
  * container whose class hashes per release (`_markdown_<hash>_…`). A
@@ -1293,11 +1260,57 @@ var DARK_ATTR = "body[data-ds-dark-theme]";
1293
1260
  * interface's own preview stays inside the rule, and `dfp-previewCode` is a
1294
1261
  * code surface like any other.
1295
1262
  */
1296
- var INTERFACE_EXCLUDES =
1297
- CODE_EXCLUDES +
1298
- ":not(.cm-editor):not(.cm-editor *):not(.dfp-previewCode):not(.dfp-previewCode *)" +
1299
- ':not([class*="_markdown_" i]):not([class*="_markdown_" i] *)' +
1300
- ":not(.dfp-previewDialog):not(.dfp-previewDialog *)";
1263
+ var INTERFACE_EXCLUDES = excludeAll(
1264
+ CODE_SURFACE_SELECTORS.concat([
1265
+ ".cm-editor",
1266
+ ".cm-editor *",
1267
+ ".dfp-previewCode",
1268
+ ".dfp-previewCode *",
1269
+ '[class*="_markdown_" i]',
1270
+ '[class*="_markdown_" i] *',
1271
+ ".dfp-previewDialog",
1272
+ ".dfp-previewDialog *",
1273
+ ])
1274
+ );
1275
+
1276
+ /**
1277
+ * Split a selector list on its top-level commas only.
1278
+ *
1279
+ * The exclusions hold a selector LIST inside `:not(…)`, so a plain
1280
+ * `split(",")` would cut a selector in half — and the per-theme rules prefix
1281
+ * every selector in the list.
1282
+ * @param {string} selectorList - a comma-separated selector list.
1283
+ * @returns {string[]} the top-level parts, verbatim.
1284
+ */
1285
+ function splitSelectorList(selectorList) {
1286
+ var parts = [];
1287
+ var current = "";
1288
+ var depth = 0;
1289
+ var quote = "";
1290
+ for (var index = 0; index < selectorList.length; index += 1) {
1291
+ var character = selectorList[index];
1292
+ if (quote !== "") {
1293
+ current += character;
1294
+ if (character === quote) quote = "";
1295
+ continue;
1296
+ }
1297
+ if (character === '"' || character === "'") {
1298
+ quote = character;
1299
+ current += character;
1300
+ continue;
1301
+ }
1302
+ if (character === "(" || character === "[") depth += 1;
1303
+ if (character === ")" || character === "]") depth -= 1;
1304
+ if (character === "," && depth === 0) {
1305
+ parts.push(current);
1306
+ current = "";
1307
+ continue;
1308
+ }
1309
+ current += character;
1310
+ }
1311
+ parts.push(current);
1312
+ return parts;
1313
+ }
1301
1314
 
1302
1315
  /**
1303
1316
  * Prefix every selector in a comma list, for the per-theme rules.
@@ -1306,7 +1319,7 @@ var INTERFACE_EXCLUDES =
1306
1319
  * @returns {string} the prefixed list.
1307
1320
  */
1308
1321
  function prefixSelector(selectorList, prefix) {
1309
- var parts = selectorList.split(",");
1322
+ var parts = splitSelectorList(selectorList);
1310
1323
  var out = [];
1311
1324
  for (var index = 0; index < parts.length; index += 1) {
1312
1325
  out.push(prefix + " " + parts[index]);
@@ -1468,9 +1481,13 @@ function buildAxisCss(axis, baseTokens, isDark) {
1468
1481
  */
1469
1482
  function pushScopedCodeFamily() {
1470
1483
  var scopedCode = [];
1471
- var surfaces = CODE_SELECTOR.split(",");
1484
+ var surfaces = splitSelectorList(CODE_SELECTOR);
1472
1485
  for (var cIndex = 0; cIndex < surfaces.length; cIndex += 1) {
1473
- scopedCode.push('[class*="_markdown_" i] ' + surfaces[cIndex]);
1486
+ var scoped = '[class*="_markdown_" i] ' + surfaces[cIndex];
1487
+ // The dark variant has to carry the theme attribute like every other
1488
+ // rule here, or a per-theme set would re-assert the LIGHT code family
1489
+ // inside the dark dialog.
1490
+ scopedCode.push(isDark ? DARK_ATTR + " " + scoped : scoped);
1474
1491
  }
1475
1492
  declarations.push(scopedCode.join(",") + "{font-family:" + mono + " !important}");
1476
1493
  }
@@ -1567,8 +1584,8 @@ function buildAxisCss(axis, baseTokens, isDark) {
1567
1584
  // (secondary size, both deltas, delta-based heading sizes, heights) at their
1568
1585
  // body-computed values. The offset is therefore added once, at the source —
1569
1586
  // `--dsh-content-font-size` becomes "official base + offset" — and the whole
1570
- // ladder follows by derivation, with nothing counted twice. The interface
1571
- // axis never touches this chain (see `bodyTokenNames`), so the conversation
1587
+ // ladder follows by derivation, with nothing counted twice. The retired
1588
+ // interface size axis never touches this chain at all, so the conversation
1572
1589
  // moves only when this axis says so, and a dialog that FOLLOWS the interface
1573
1590
  // follows it through this value.
1574
1591
  var dialogOffsetValue = axis[SIZE_DIALOG_FIELD];
@@ -1980,18 +1997,137 @@ var PRESETS = {
1980
1997
  };
1981
1998
 
1982
1999
  /**
1983
- * Legacy single-purpose helper kept for compatibility: scale the size and the
1984
- * height inside one simple `size/height <family>` shorthand.
1985
- * @param {string} value - a value shaped like `11px/19px var(--ds-font-family-code)`.
1986
- * @param {number} scale - the ratio to apply.
1987
- * @returns {string|null} the scaled shorthand, or null when the shape differs.
2000
+ * The curated groups, in the order the picker lists them.
1988
2001
  */
1989
- function scaleFontShorthand(value, scale) {
1990
- var parts = parseShorthand(value);
1991
- if (parts === null || parts.lead !== "") return null;
1992
- return (
1993
- "calc((" + parts.size + ") * " + scale + ")/calc((" + parts.height + ") * " + scale + ") " + parts.family
1994
- );
2002
+ var PRESET_GROUPS = [
2003
+ { label: "stack.groupMono", families: PRESETS.mono },
2004
+ { label: "stack.groupCjk", families: PRESETS.cjk },
2005
+ { label: "stack.groupLatin", families: PRESETS.latin },
2006
+ { label: "stack.groupGeneric", families: PRESETS.generic },
2007
+ ];
2008
+
2009
+ /**
2010
+ * How many enumerated families the "installed on this machine" group shows.
2011
+ * The curated groups are the short list; this one is the complete one, so the
2012
+ * cap is only a guard against a machine with an absurd number of families.
2013
+ */
2014
+ var MAX_LOCAL_FONTS = 240;
2015
+
2016
+ /**
2017
+ * Build the family picker's groups.
2018
+ *
2019
+ * The curated groups are ALWAYS listed: whether or not the browser enumerated
2020
+ * local fonts, and whether or not a search is running. The old shape skipped
2021
+ * them whenever a working catalog met a non-empty query, so a curated family
2022
+ * this machine has not installed could not be found by name at all. Every
2023
+ * family is listed in exactly one group — the local enumeration gives way to
2024
+ * the curated headings rather than repeating what they already offer.
2025
+ *
2026
+ * @param {object} input - the picker's state.
2027
+ * @param {string[]} [input.stack] - the families the stored value names.
2028
+ * @param {boolean} [input.single] - single-pick mode (a West/CJK slot).
2029
+ * @param {string} [input.query] - the search text.
2030
+ * @param {{status: string, families: string[]}} [input.catalog] - enumeration.
2031
+ * @param {number} [input.maxLocal] - cap on the local group's rows.
2032
+ * @returns {Array<{label: string, families: string[]}>} the groups, in the
2033
+ * order the picker displays them.
2034
+ */
2035
+ function pickerGroups(input) {
2036
+ var stack = Array.isArray(input.stack) ? input.stack : [];
2037
+ var single = input.single === true;
2038
+ var query = typeof input.query === "string" ? input.query : "";
2039
+ var catalog =
2040
+ input.catalog && typeof input.catalog === "object"
2041
+ ? input.catalog
2042
+ : { status: "loading", families: [] };
2043
+ var maxLocal = typeof input.maxLocal === "number" ? input.maxLocal : MAX_LOCAL_FONTS;
2044
+ var needle = query.trim().toLowerCase();
2045
+ var index;
2046
+ var matches = function (name) {
2047
+ return needle === "" || String(name).toLowerCase().indexOf(needle) >= 0;
2048
+ };
2049
+
2050
+ // Which families the stack already names. In single mode the stack IS the
2051
+ // one pick, and it stays visible in the curated groups (with its check mark)
2052
+ // so the reader can see where it sits among its own kind.
2053
+ var inStack = {};
2054
+ if (!single) {
2055
+ for (index = 0; index < stack.length; index += 1) {
2056
+ inStack[String(stack[index]).toLowerCase()] = true;
2057
+ }
2058
+ }
2059
+
2060
+ // The curated groups are built FIRST, and the local list gives way to them:
2061
+ // they are the short list the reader scans, and on a real Windows machine
2062
+ // most of the Latin group is installed, so letting the local list win would
2063
+ // empty the curated headings. Everything lands in exactly one group.
2064
+ var curatedLower = {};
2065
+ var curatedGroups = [];
2066
+ for (index = 0; index < PRESET_GROUPS.length; index += 1) {
2067
+ var curated = PRESET_GROUPS[index];
2068
+ var rows = [];
2069
+ for (var nameIndex = 0; nameIndex < curated.families.length; nameIndex += 1) {
2070
+ var name = sanitizeFamily(curated.families[nameIndex]);
2071
+ if (name === "" || !matches(name)) continue;
2072
+ var key = name.toLowerCase();
2073
+ if (inStack[key] === true || curatedLower[key] === true) continue;
2074
+ curatedLower[key] = true;
2075
+ rows.push(name);
2076
+ }
2077
+ if (rows.length > 0) curatedGroups.push({ label: curated.label, families: rows });
2078
+ }
2079
+
2080
+ // Then the enumeration: the complete list, minus everything already offered
2081
+ // by a heading above it.
2082
+ var localRows = [];
2083
+ if (catalog.status === "ready") {
2084
+ var locals = Array.isArray(catalog.families) ? catalog.families : [];
2085
+ for (index = 0; index < locals.length; index += 1) {
2086
+ var localName = sanitizeFamily(locals[index]);
2087
+ if (localName === "" || !matches(localName)) continue;
2088
+ var localKey = localName.toLowerCase();
2089
+ if (inStack[localKey] === true || curatedLower[localKey] === true) continue;
2090
+ curatedLower[localKey] = true;
2091
+ localRows.push(localName);
2092
+ if (localRows.length >= maxLocal) break;
2093
+ }
2094
+ }
2095
+
2096
+ var groups = [];
2097
+ var selectedRows = [];
2098
+ for (index = 0; index < stack.length; index += 1) {
2099
+ if (matches(stack[index])) selectedRows.push(stack[index]);
2100
+ }
2101
+ if (selectedRows.length > 0) {
2102
+ groups.push({ label: "stack.groupSelected", families: selectedRows });
2103
+ }
2104
+ for (index = 0; index < curatedGroups.length; index += 1) {
2105
+ groups.push(curatedGroups[index]);
2106
+ }
2107
+ if (localRows.length > 0) {
2108
+ groups.push({ label: "stack.groupLocal", families: localRows });
2109
+ }
2110
+ return groups;
2111
+ }
2112
+
2113
+ /**
2114
+ * Whether a cached font-enumeration verdict may be asked for again.
2115
+ *
2116
+ * A working enumeration is final for the session, and a browser that has no
2117
+ * such API at all will never grow one — but a refusal is not: the user can
2118
+ * grant the font permission from the browser's prompt or its site settings.
2119
+ * Retrying is cheap, because a still-blocked permission is rejected instantly
2120
+ * instead of prompting again.
2121
+ *
2122
+ * @param {{status?: string, at?: number, permanent?: boolean}|null} cache - the cached verdict.
2123
+ * @param {number} now - the current time, in milliseconds.
2124
+ * @param {number} retryMs - how long a refusal is trusted.
2125
+ * @returns {boolean} true when the enumeration should run again.
2126
+ */
2127
+ function catalogRefreshDue(cache, now, retryMs) {
2128
+ if (cache === null || cache === undefined) return true;
2129
+ if (cache.permanent === true || cache.status === "ready") return false;
2130
+ return now - (cache.at || 0) >= retryMs;
1995
2131
  }
1996
2132
 
1997
2133
  var shared = {
@@ -2018,6 +2154,8 @@ var shared = {
2018
2154
  ACTIVE_PRESET_FIELD: ACTIVE_PRESET_FIELD,
2019
2155
  UI_FOLLOWS_FIELD: UI_FOLLOWS_FIELD,
2020
2156
  VALUE_FIELDS: VALUE_FIELDS,
2157
+ RETIRED_FIELDS: RETIRED_FIELDS,
2158
+ DURABLE_FIELDS: DURABLE_FIELDS,
2021
2159
  SIZE_MIN: SIZE_MIN,
2022
2160
  SIZE_MAX: SIZE_MAX,
2023
2161
  WEIGHT_MIN: WEIGHT_MIN,
@@ -2030,7 +2168,6 @@ var shared = {
2030
2168
  LIGATURES_DEFAULT: LIGATURES_DEFAULT,
2031
2169
  LIGATURES_ON: LIGATURES_ON,
2032
2170
  LIGATURES_OFF: LIGATURES_OFF,
2033
- MARKER: MARKER,
2034
2171
  STYLE_TAG: STYLE_TAG,
2035
2172
  CARD_STYLE_TAG: CARD_STYLE_TAG,
2036
2173
  DEFAULTS: DEFAULTS,
@@ -2041,7 +2178,17 @@ var shared = {
2041
2178
  MAX_PRESET_NAME: MAX_PRESET_NAME,
2042
2179
  FALLBACK_TOKENS: FALLBACK_TOKENS,
2043
2180
  PRESETS: PRESETS,
2181
+ PRESET_GROUPS: PRESET_GROUPS,
2182
+ MAX_LOCAL_FONTS: MAX_LOCAL_FONTS,
2183
+ pickerGroups: pickerGroups,
2184
+ catalogRefreshDue: catalogRefreshDue,
2044
2185
  MARKDOWN_SELECTOR: MARKDOWN_SELECTOR,
2186
+ CODE_SURFACE_SELECTORS: CODE_SURFACE_SELECTORS,
2187
+ CODE_EXCLUDES: CODE_EXCLUDES,
2188
+ INTERFACE_EXCLUDES: INTERFACE_EXCLUDES,
2189
+ excludeAll: excludeAll,
2190
+ splitSelectorList: splitSelectorList,
2191
+ prefixSelector: prefixSelector,
2045
2192
  DARK_ATTR: DARK_ATTR,
2046
2193
  sanitize: sanitize,
2047
2194
  sanitizeFamily: sanitizeFamily,
@@ -2072,19 +2219,12 @@ var shared = {
2072
2219
  isMarkdownToken: isMarkdownToken,
2073
2220
  isDeltaToken: isDeltaToken,
2074
2221
  isShorthandName: isShorthandName,
2075
- isShorthandToken: isShorthandToken,
2076
2222
  isScaledToken: isScaledToken,
2077
2223
  tokenNames: tokenNames,
2078
- codeTokenNames: codeTokenNames,
2079
2224
  markdownTokenNames: markdownTokenNames,
2080
- bodyTokenNames: bodyTokenNames,
2081
- uiLineTokenNames: uiLineTokenNames,
2082
- deltaTokenNames: deltaTokenNames,
2083
2225
  shorthandTokens: shorthandTokens,
2084
2226
  markdownShorthandTokens: markdownShorthandTokens,
2085
- uiShorthandTokens: uiShorthandTokens,
2086
2227
  codeShorthandTokens: codeShorthandTokens,
2087
- scaleFontShorthand: scaleFontShorthand,
2088
2228
  isGenericFamilyName: isGenericFamilyName,
2089
2229
  isCJKFamilyName: isCJKFamilyName,
2090
2230
  setWestEntry: setWestEntry,
@@ -2158,6 +2298,7 @@ var PRESETS_FIELD = shared.PRESETS_FIELD;
2158
2298
  var ACTIVE_PRESET_FIELD = shared.ACTIVE_PRESET_FIELD;
2159
2299
  var UI_FOLLOWS_FIELD = shared.UI_FOLLOWS_FIELD;
2160
2300
  var VALUE_FIELDS = shared.VALUE_FIELDS;
2301
+ var RETIRED_FIELDS = shared.RETIRED_FIELDS;
2161
2302
  var SIZE_MIN = shared.SIZE_MIN;
2162
2303
  var SIZE_MAX = shared.SIZE_MAX;
2163
2304
  var WEIGHT_MIN = shared.WEIGHT_MIN;
@@ -2223,12 +2364,6 @@ var CHEVRON_PATH =
2223
2364
  /** Panel height cap, used to decide whether it opens up or down. */
2224
2365
  var PANEL_HEIGHT = 380;
2225
2366
 
2226
- /**
2227
- * Cap on how many enumerated families the picker lists at once: a machine can
2228
- * report well over a thousand, which would make the panel useless.
2229
- */
2230
- var MAX_VISIBLE_FONTS = 240;
2231
-
2232
2367
  /* ------------------------------------------------------------------ *
2233
2368
  * copy
2234
2369
  * ------------------------------------------------------------------ */
@@ -2287,13 +2422,9 @@ var DICTS = {
2287
2422
  "monoWest.label": "Code · Western",
2288
2423
  "monoEast.label": "Code · CJK",
2289
2424
  "split.pick": "Choose…",
2290
- "split.unset": "Not set",
2291
2425
  "split.remove": "Remove {name}",
2292
2426
  "split.rest": "Other fallbacks (reorder them in Advanced): {names}",
2293
2427
 
2294
- "size.bodyLabel": "Interface font size offset",
2295
- "size.bodyHint":
2296
- "Adds {offset} to interface text sizes, on top of DSH's own font-size setting. 0 keeps DSH's sizes.",
2297
2428
  "size.dialogLabel": "Conversation font size offset",
2298
2429
  "size.dialogHint":
2299
2430
  "Adds {offset} to the conversation text sizes, on top of DSH's own font-size setting. 0 keeps DSH's sizes.",
@@ -2312,9 +2443,6 @@ var DICTS = {
2312
2443
  "weight.codeHint":
2313
2444
  "Overrides code blocks, inline code and terminal output only. 400 or unset keeps DSH's own weight.",
2314
2445
 
2315
- "line.bodyLabel": "Interface line height",
2316
- "line.bodyHint":
2317
- "Scales every interface line height by {ratio}. 100% keeps DSH's own line heights.",
2318
2446
  "line.dialogLabel": "Conversation line height",
2319
2447
  "line.dialogHint":
2320
2448
  "Scales every conversation line height by {ratio}. 100% keeps DSH's own line heights.",
@@ -2334,7 +2462,6 @@ var DICTS = {
2334
2462
  "Advanced font-feature-settings value, e.g. \"ss01\" on, \"cv01\" 1. Invalid values are ignored.",
2335
2463
  "feat.placeholder": "\"ss01\" on",
2336
2464
 
2337
- "synth.label": "Refuse synthetic styles",
2338
2465
  "synth.italic": "No faux italic",
2339
2466
  "synth.italicHint":
2340
2467
  "CJK faces have no italic, so the browser tilts them. On keeps marked text upright.",
@@ -2348,15 +2475,6 @@ var DICTS = {
2348
2475
  "section.ui": "Interface",
2349
2476
  "section.dialog": "Conversation",
2350
2477
  "section.code": "Code",
2351
- "section.fine": "Global fine-tuning",
2352
- "section.presets": "Presets",
2353
- "section.uiHint":
2354
- "Sidebars, headings, buttons and every other chrome text.",
2355
- "section.dialogHint":
2356
- "The conversation markdown: paragraphs, tables and headings.",
2357
- "section.codeHint": "Code blocks, inline code and terminal output.",
2358
- "section.fineHint":
2359
- "Settings that apply to the whole page.",
2360
2478
  "section.open": "Expand {name}",
2361
2479
  "section.close": "Collapse {name}",
2362
2480
 
@@ -2365,13 +2483,6 @@ var DICTS = {
2365
2483
  "ui.own": "Own values",
2366
2484
  "dialog.default": "DSH defaults",
2367
2485
 
2368
- "theme.label": "Per-theme values",
2369
- "theme.on": "Light and dark keep separate values",
2370
- "theme.edit": "Editing theme",
2371
- "theme.light": "Light",
2372
- "theme.dark": "Dark",
2373
- "theme.mismatch":
2374
- "The page is in {active} mode; the preview below shows the values being edited.",
2375
2486
 
2376
2487
  "dialog.label": "Conversation font",
2377
2488
  "dialog.hint":
@@ -2379,11 +2490,6 @@ var DICTS = {
2379
2490
  "dialogWest.label": "Conversation · Western",
2380
2491
  "dialogEast.label": "Conversation · CJK",
2381
2492
 
2382
- "preset.save": "Save current",
2383
- "preset.namePlaceholder": "Preset name",
2384
- "preset.apply": "Apply",
2385
- "preset.overwrite": "Overwrite",
2386
- "preset.delete": "Delete",
2387
2493
  "preset.export": "Export",
2388
2494
  "preset.import": "Import",
2389
2495
  "preset.importPlaceholder": "Paste an exported preset JSON here",
@@ -2394,18 +2500,13 @@ var DICTS = {
2394
2500
  "preset.renamed": "Renamed to “{name}”.",
2395
2501
  "preset.autoSave": "With a preset selected, every change saves into it automatically.",
2396
2502
  "preset.nameUsed": "That name is already taken.",
2397
- "preset.saved": "Saved “{name}”.",
2398
2503
  "preset.applied": "Applied “{name}”.",
2399
- "preset.deleted": "Deleted “{name}”.",
2400
- "preset.updated": "Overwrote “{name}”.",
2401
2504
  "preset.exported": "Presets copied to the clipboard.",
2402
2505
  "preset.imported": "Imported {count} preset(s).",
2403
2506
  "preset.importBad": "That text is not a valid preset export.",
2404
- "preset.full": "The preset list is full ({max}).",
2405
- "preset.empty": "No presets saved yet.",
2406
- "preset.nameTaken": "“{name}” already exists — saving overwrites it.",
2507
+ "preset.exportManual": "Clipboard unavailable — press Ctrl+C to copy the selected text.",
2508
+ "preset.writeFailed": "The setting was not saved: {message}",
2407
2509
 
2408
- "preview.label": "Preview",
2409
2510
  "preview.sansCaption": "Interface",
2410
2511
  "preview.dialogCaption": "Conversation",
2411
2512
  "preview.monoCaption": "Code",
@@ -2413,8 +2514,6 @@ var DICTS = {
2413
2514
  "The quick brown fox jumps over the lazy dog — 中文排版预览,标点符号,数字 0123456789。",
2414
2515
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
2415
2516
 
2416
- "footnote.local":
2417
- "Stored in the Host settings document. Every change applies immediately.",
2418
2517
  },
2419
2518
  zh: {
2420
2519
  "card.title": "字体增强",
@@ -2467,13 +2566,9 @@ var DICTS = {
2467
2566
  "monoWest.label": "代码 · 西文字体",
2468
2567
  "monoEast.label": "代码 · 中文字体",
2469
2568
  "split.pick": "选择…",
2470
- "split.unset": "未选择",
2471
2569
  "split.remove": "移除 {name}",
2472
2570
  "split.rest": "其余回退项(在高级模式中排序):{names}",
2473
2571
 
2474
- "size.bodyLabel": "界面字号偏移",
2475
- "size.bodyHint":
2476
- "给界面文字统一加 {offset},与设置里的「字号大小」叠加;0 表示保持原样。",
2477
2572
  "size.dialogLabel": "对话字号偏移",
2478
2573
  "size.dialogHint":
2479
2574
  "给对话文字统一加 {offset},与设置里的「字号大小」叠加;0 表示保持 DSH 原样。",
@@ -2491,8 +2586,6 @@ var DICTS = {
2491
2586
  "weight.codeHint":
2492
2587
  "只覆盖代码块、行内代码与终端输出;400 或未设置表示保持 DSH 原样。",
2493
2588
 
2494
- "line.bodyLabel": "界面行高",
2495
- "line.bodyHint": "把界面行高整体缩放为 {ratio};100% 表示保持原样。",
2496
2589
  "line.dialogLabel": "对话行高",
2497
2590
  "line.dialogHint": "把对话行高整体缩放为 {ratio};100% 表示保持 DSH 原样。",
2498
2591
  "line.codeLabel": "代码行高",
@@ -2510,7 +2603,6 @@ var DICTS = {
2510
2603
  "高级的 font-feature-settings 值,例如 ss01 on、cv01 1;非法值会被忽略。",
2511
2604
  "feat.placeholder": "\"ss01\" on",
2512
2605
 
2513
- "synth.label": "拒绝合成样式",
2514
2606
  "synth.italic": "禁用伪斜体",
2515
2607
  "synth.italicHint":
2516
2608
  "中文字体没有斜体,浏览器会把标记文本硬掰歪;开启后保持直立。",
@@ -2523,21 +2615,9 @@ var DICTS = {
2523
2615
  "section.ui": "界面",
2524
2616
  "section.dialog": "对话",
2525
2617
  "section.code": "代码",
2526
- "section.fine": "全局微调",
2527
- "section.presets": "预设方案",
2528
- "section.uiHint": "侧栏、标题、按钮等界面文字。",
2529
- "section.dialogHint": "会话里的 Markdown:段落、表格与标题。",
2530
- "section.codeHint": "代码块、行内代码与终端输出。",
2531
- "section.fineHint": "作用于整页的选项。",
2532
2618
  "section.open": "展开{name}",
2533
2619
  "section.close": "收起{name}",
2534
2620
 
2535
- "theme.label": "分主题数值",
2536
- "theme.on": "深浅色各存一套数值",
2537
- "theme.edit": "正在编辑",
2538
- "theme.light": "浅色",
2539
- "theme.dark": "深色",
2540
- "theme.mismatch": "当前页面是{active}主题,下方预览显示的是正在编辑的那套数值。",
2541
2621
 
2542
2622
  "ui.follow": "跟随对话设置",
2543
2623
  "ui.followHint": "字体与字重",
@@ -2550,11 +2630,6 @@ var DICTS = {
2550
2630
  "dialogWest.label": "对话 · 西文字体",
2551
2631
  "dialogEast.label": "对话 · 中文字体",
2552
2632
 
2553
- "preset.save": "保存当前",
2554
- "preset.namePlaceholder": "方案名称",
2555
- "preset.apply": "应用",
2556
- "preset.overwrite": "覆盖",
2557
- "preset.delete": "删除",
2558
2633
  "preset.export": "导出",
2559
2634
  "preset.import": "导入",
2560
2635
  "preset.importPlaceholder": "粘贴导出的方案内容",
@@ -2565,18 +2640,13 @@ var DICTS = {
2565
2640
  "preset.renamed": "已改名为“{name}”。",
2566
2641
  "preset.autoSave": "选中方案后,所有改动自动存入该方案。",
2567
2642
  "preset.nameUsed": "这个名字已被占用。",
2568
- "preset.saved": "已保存“{name}”。",
2569
2643
  "preset.applied": "已应用“{name}”。",
2570
- "preset.deleted": "已删除“{name}”。",
2571
- "preset.updated": "已覆盖“{name}”。",
2572
2644
  "preset.exported": "方案已复制到剪贴板。",
2573
2645
  "preset.imported": "已导入 {count} 个方案。",
2574
2646
  "preset.importBad": "这段内容不是有效的方案导出。",
2575
- "preset.full": "方案列表已满({max})。",
2576
- "preset.empty": "还没有保存方案。",
2577
- "preset.nameTaken": "“{name}”已存在,保存将覆盖它。",
2647
+ "preset.exportManual": "剪贴板不可用,按 Ctrl+C 复制下方已选中的内容。",
2648
+ "preset.writeFailed": "设置没有保存成功:{message}",
2578
2649
 
2579
- "preview.label": "预览",
2580
2650
  "preview.sansCaption": "界面",
2581
2651
  "preview.dialogCaption": "对话",
2582
2652
  "preview.monoCaption": "代码",
@@ -2584,7 +2654,6 @@ var DICTS = {
2584
2654
  "The quick brown fox jumps over the lazy dog —— 中文排版预览,标点符号,数字 0123456789。",
2585
2655
  "preview.code": "const greet = (name) => `hello ${name}`; // => != >= -> 代码预览",
2586
2656
 
2587
- "footnote.local": "保存在宿主设置文档里;每次改动立即生效。",
2588
2657
  },
2589
2658
  };
2590
2659
 
@@ -2687,7 +2756,6 @@ var CARD_CSS = [
2687
2756
  ".dfp-miniButton{appearance:none;font:inherit;cursor:pointer;white-space:nowrap;flex:none;border-radius:6px;padding:3px 8px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);background:0 0;border:.5px solid var(--dsw-alias-border-l3)}",
2688
2757
  ".dfp-miniButton:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-specific-sidebar-nav-item-hover)}",
2689
2758
  ".dfp-miniButton:disabled{opacity:.4;cursor:default}",
2690
- ".dfp-presetForm{align-items:center;gap:8px;margin-top:10px;display:flex}",
2691
2759
  ".dfp-textInput{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;font:inherit;font-size:13px;line-height:18px}",
2692
2760
  // The auto-save hint and the transient message share one row: the hint holds
2693
2761
  // the left, the message fades in on the right. The row is always mounted and
@@ -2736,6 +2804,7 @@ var CARD_CSS = [
2736
2804
 
2737
2805
  ".dfp-panel{box-sizing:border-box;position:fixed;z-index:1200;flex-direction:column;width:320px;max-height:380px;padding:8px;background:var(--dsw-alias-bg-layer-2);border:.5px solid var(--dsw-alias-border-l3);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 12px 32px #00000024);display:flex;gap:6px}",
2738
2806
  ".dfp-search{width:100%;box-sizing:border-box;padding:5px 8px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border:.5px solid var(--dsw-alias-border-l3);border-radius:8px;font:inherit;font-size:13px;line-height:18px}",
2807
+ ".dfp-exportText{resize:vertical;min-height:54px;font-family:var(--ds-font-family-code,ui-monospace,monospace);font-size:12px;line-height:17px}",
2739
2808
  ".dfp-note{padding:4px 2px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:16px}",
2740
2809
  ".dfp-list{flex-direction:column;gap:1px;flex:1;min-height:0;overflow-y:auto;display:flex}",
2741
2810
  ".dfp-group{padding:6px 6px 2px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;position:sticky;top:0;background:var(--dsw-alias-bg-layer-2)}",
@@ -3321,24 +3390,45 @@ function PresetSelect(props) {
3321
3390
  * ------------------------------------------------------------------ */
3322
3391
 
3323
3392
  /**
3324
- * @typedef {{status: "unsupported"|"loading"|"denied"|"ready", families: string[]}} Catalog
3393
+ * @typedef {{status: "unsupported"|"loading"|"denied"|"ready", families: string[],
3394
+ * at?: number, permanent?: boolean}} Catalog
3325
3395
  */
3326
3396
 
3327
3397
  /** Enumeration is session-stable, so it is resolved once and kept. */
3328
3398
  var catalogCache = null;
3329
3399
 
3400
+ /**
3401
+ * How long a refused (or empty) enumeration is trusted before the picker asks
3402
+ * again. Answering "denied" and never retrying is wrong in the case that
3403
+ * actually happens: the user grants the permission from the browser's prompt
3404
+ * or site settings and expects the list to fill in — and when the permission
3405
+ * is still blocked the browser rejects the call instantly without prompting,
3406
+ * so the retry costs nothing.
3407
+ */
3408
+ var CATALOG_RETRY_MS = 20000;
3409
+
3410
+ /** The clock, injectable so the retry window is testable without waiting. */
3411
+ var catalogNow = function () {
3412
+ return Date.now();
3413
+ };
3414
+
3330
3415
  /**
3331
3416
  * Enumerate installed families; the built-in presets stand in when the browser
3332
3417
  * cannot or will not enumerate them.
3418
+ * @param {boolean} [force] - ignore a cached refusal and ask again.
3333
3419
  * @returns {Promise<Catalog>} the catalog.
3334
3420
  */
3335
- async function loadCatalog() {
3336
- if (catalogCache !== null) return catalogCache;
3421
+ async function loadCatalog(force) {
3422
+ var now = catalogNow();
3423
+ if (force !== true && !shared.catalogRefreshDue(catalogCache, now, CATALOG_RETRY_MS)) {
3424
+ return catalogCache;
3425
+ }
3337
3426
  var host = /** @type {{queryLocalFonts?: () => Promise<Array<{family: string}>>}} */ (
3338
3427
  globalThis
3339
3428
  );
3340
3429
  if (typeof host.queryLocalFonts !== "function") {
3341
- catalogCache = { status: "unsupported", families: [] };
3430
+ // No API at all: retrying cannot help, and the picker says so.
3431
+ catalogCache = { status: "unsupported", families: [], at: now, permanent: true };
3342
3432
  return catalogCache;
3343
3433
  }
3344
3434
  try {
@@ -3358,11 +3448,11 @@ async function loadCatalog() {
3358
3448
  });
3359
3449
  catalogCache =
3360
3450
  families.length === 0
3361
- ? { status: "unsupported", families: [] }
3362
- : { status: "ready", families: families };
3451
+ ? { status: "unsupported", families: [], at: now }
3452
+ : { status: "ready", families: families, at: now };
3363
3453
  } catch (error) {
3364
3454
  // A refused permission is the common case here: `queryLocalFonts` prompts.
3365
- catalogCache = { status: "denied", families: [] };
3455
+ catalogCache = { status: "denied", families: [], at: now };
3366
3456
  }
3367
3457
  return catalogCache;
3368
3458
  }
@@ -3425,13 +3515,65 @@ function classifyFamily(name) {
3425
3515
  return verdict;
3426
3516
  }
3427
3517
 
3428
- /** Preset groups, in the order the picker lists them. */
3429
- var PRESET_GROUPS = [
3430
- { label: "stack.groupMono", families: PRESETS.mono },
3431
- { label: "stack.groupCjk", families: PRESETS.cjk },
3432
- { label: "stack.groupLatin", families: PRESETS.latin },
3433
- { label: "stack.groupGeneric", families: PRESETS.generic },
3434
- ];
3518
+ /* ------------------------------------------------------------------ *
3519
+ * clipboard
3520
+ * ------------------------------------------------------------------ */
3521
+
3522
+ /**
3523
+ * Copy through the legacy selection path.
3524
+ *
3525
+ * This is not a museum piece: the async Clipboard API only exists in a secure
3526
+ * context, and the deployment note in this repository's README documents a
3527
+ * remote HTTP origin where `navigator.clipboard` is simply absent. A
3528
+ * selection-based copy still works there, and it also works when the async
3529
+ * call is refused without the clipboard-write permission.
3530
+ * @param {string} text - what to put on the clipboard.
3531
+ * @returns {boolean} whether the copy command reported success.
3532
+ */
3533
+ function copyBySelection(text) {
3534
+ if (typeof document === "undefined" || !document.body) return false;
3535
+ if (typeof document.execCommand !== "function") return false;
3536
+ var area = null;
3537
+ try {
3538
+ area = document.createElement("textarea");
3539
+ area.value = text;
3540
+ area.setAttribute("readonly", "");
3541
+ // Off-screen rather than hidden: `display:none` cannot hold a selection.
3542
+ area.style.position = "fixed";
3543
+ area.style.top = "-1000px";
3544
+ area.style.opacity = "0";
3545
+ document.body.appendChild(area);
3546
+ area.select();
3547
+ return document.execCommand("copy") === true;
3548
+ } catch (error) {
3549
+ return false;
3550
+ } finally {
3551
+ if (area !== null && area.parentNode) area.parentNode.removeChild(area);
3552
+ }
3553
+ }
3554
+
3555
+ /**
3556
+ * Copy text, trying the async API first and the selection fallback after it.
3557
+ * @param {string} text - what to put on the clipboard.
3558
+ * @returns {Promise<boolean>} whether the text reached the clipboard.
3559
+ */
3560
+ async function copyText(text) {
3561
+ var nav = globalThis.navigator;
3562
+ if (
3563
+ nav !== undefined &&
3564
+ nav !== null &&
3565
+ nav.clipboard !== undefined &&
3566
+ typeof nav.clipboard.writeText === "function"
3567
+ ) {
3568
+ try {
3569
+ await nav.clipboard.writeText(text);
3570
+ return true;
3571
+ } catch (error) {
3572
+ // No permission, or no clipboard in this context: fall through.
3573
+ }
3574
+ }
3575
+ return copyBySelection(text);
3576
+ }
3435
3577
 
3436
3578
  /* ------------------------------------------------------------------ *
3437
3579
  * the family picker
@@ -3576,7 +3718,6 @@ function StackPicker(props) {
3576
3718
  commit(families.concat([name]));
3577
3719
  };
3578
3720
 
3579
- var needle = query.trim().toLowerCase();
3580
3721
  var selectedLower = {};
3581
3722
  if (single) {
3582
3723
  if (typeof props.value === "string" && props.value !== "") {
@@ -3587,47 +3728,15 @@ function StackPicker(props) {
3587
3728
  selectedLower[families[selectedIndex].toLowerCase()] = true;
3588
3729
  }
3589
3730
  }
3590
- var matches = function (name) {
3591
- return needle === "" || name.toLowerCase().indexOf(needle) >= 0;
3592
- };
3593
- var options = [];
3594
- var pushGroup = function (labelKey, names, limit, skipSelected) {
3595
- var rows = [];
3596
- for (var index = 0; index < names.length; index += 1) {
3597
- var name = sanitizeFamily(names[index]);
3598
- if (name === "" || !matches(name)) continue;
3599
- if (skipSelected === true && selectedLower[name.toLowerCase()] === true) continue;
3600
- rows.push(name);
3601
- if (limit !== undefined && rows.length >= limit) break;
3602
- }
3603
- if (rows.length > 0) options.push({ label: labelKey, families: rows });
3604
- };
3605
-
3606
- var selectedRows = [];
3607
- for (var familyIndex = 0; familyIndex < families.length; familyIndex += 1) {
3608
- if (matches(families[familyIndex])) selectedRows.push(families[familyIndex]);
3609
- }
3610
- if (selectedRows.length > 0) {
3611
- options.push({ label: "stack.groupSelected", families: selectedRows });
3612
- }
3613
-
3614
- if (catalog.status === "ready") {
3615
- if (needle === "") {
3616
- for (var groupIndex = 0; groupIndex < PRESET_GROUPS.length; groupIndex += 1) {
3617
- pushGroup(PRESET_GROUPS[groupIndex].label, PRESET_GROUPS[groupIndex].families, undefined, !single);
3618
- }
3619
- }
3620
- pushGroup("stack.groupLocal", catalog.families, MAX_VISIBLE_FONTS, !single);
3621
- } else {
3622
- for (var fallbackIndex = 0; fallbackIndex < PRESET_GROUPS.length; fallbackIndex += 1) {
3623
- pushGroup(
3624
- PRESET_GROUPS[fallbackIndex].label,
3625
- PRESET_GROUPS[fallbackIndex].families,
3626
- undefined,
3627
- !single
3628
- );
3629
- }
3630
- }
3731
+ // The grouping itself is pure and lives in the shared core, where it is
3732
+ // covered without a browser: which rows appear, in which order, and never
3733
+ // twice.
3734
+ var options = shared.pickerGroups({
3735
+ stack: families,
3736
+ single: single,
3737
+ query: query,
3738
+ catalog: catalog,
3739
+ });
3631
3740
 
3632
3741
  var typed = query.trim();
3633
3742
  var typedKnown = false;
@@ -4105,6 +4214,8 @@ function FontCard(props) {
4105
4214
  // effect runs again instead of ignoring the second one.
4106
4215
  var [presetStatus, setPresetStatus] = useState({ text: "", id: 0 });
4107
4216
  var [statusShown, setStatusShown] = useState(false);
4217
+ /** The exported JSON when the clipboard refused it: shown selected instead. */
4218
+ var [exportText, setExportText] = useState("");
4108
4219
  var [renaming, setRenaming] = useState(false);
4109
4220
  var [importOpen, setImportOpen] = useState(false);
4110
4221
  /** True while the card itself writes a whole preset's values: the edits
@@ -4191,18 +4302,45 @@ function FontCard(props) {
4191
4302
  clear(field);
4192
4303
  mirrorPreset(field, DEFAULTS[field]);
4193
4304
  };
4305
+ /**
4306
+ * Report a write the settings document refused. A silent failure is the
4307
+ * worst outcome here: the control goes back to showing the stored value and
4308
+ * the user is left believing the change was saved.
4309
+ * @param {unknown} error - the rejection reason.
4310
+ */
4311
+ var writeFailed = function (error) {
4312
+ var message = error && error.message ? String(error.message) : String(error);
4313
+ if (message.length > 160) message = message.slice(0, 160) + "…";
4314
+ showStatus(t("preset.writeFailed", { message: message }));
4315
+ };
4194
4316
  var submit = function (field, value) {
4195
- var result = scope.set(field, value);
4317
+ var result;
4318
+ try {
4319
+ result = scope.set(field, value);
4320
+ } catch (error) {
4321
+ writeFailed(error);
4322
+ return;
4323
+ }
4196
4324
  if (result && typeof result.catch === "function") {
4197
- result.catch(function () {
4198
- // A failed write reloads the Host state through the scope itself.
4325
+ result.catch(function (error) {
4326
+ // The scope reloads the Host state itself; the status row is what
4327
+ // keeps the user from thinking the change stuck.
4328
+ writeFailed(error);
4199
4329
  });
4200
4330
  }
4201
4331
  };
4202
4332
  var clear = function (field) {
4203
- var result = scope.unset(field);
4333
+ var result;
4334
+ try {
4335
+ result = scope.unset(field);
4336
+ } catch (error) {
4337
+ writeFailed(error);
4338
+ return;
4339
+ }
4204
4340
  if (result && typeof result.catch === "function") {
4205
- result.catch(function () {});
4341
+ result.catch(function (error) {
4342
+ writeFailed(error);
4343
+ });
4206
4344
  }
4207
4345
  };
4208
4346
  var overridden = function (field) {
@@ -4542,21 +4680,23 @@ function FontCard(props) {
4542
4680
  showStatus(t("preset.renamed", { name: name }));
4543
4681
  };
4544
4682
 
4683
+ /**
4684
+ * Hand the preset list over as JSON: the clipboard when it is available,
4685
+ * and otherwise a selected read-only box with the same text in it. The old
4686
+ * fallback printed the export into the one-line status row, which truncated
4687
+ * the JSON at 120 characters — a message that could not be copied from.
4688
+ */
4545
4689
  var exportPresets = function () {
4546
4690
  var text = JSON.stringify(presetList);
4547
- var navigator = globalThis.navigator;
4548
- if (navigator !== undefined && navigator.clipboard !== undefined) {
4549
- navigator.clipboard
4550
- .writeText(text)
4551
- .then(function () {
4552
- showStatus(t("preset.exported"));
4553
- })
4554
- .catch(function () {
4555
- showStatus(text.slice(0, 200) + "…");
4556
- });
4557
- } else {
4558
- showStatus(text.slice(0, 120) + "…");
4559
- }
4691
+ void copyText(text).then(function (copied) {
4692
+ if (copied) {
4693
+ setExportText("");
4694
+ showStatus(t("preset.exported"));
4695
+ return;
4696
+ }
4697
+ setExportText(text);
4698
+ showStatus(t("preset.exportManual"));
4699
+ });
4560
4700
  };
4561
4701
  var importPresets = function (text) {
4562
4702
  var parsed = normalizePresets(text);
@@ -4852,6 +4992,25 @@ function FontCard(props) {
4852
4992
  if (text !== "") importPresets(text);
4853
4993
  },
4854
4994
  })
4995
+ : null,
4996
+ // The clipboard-less export: the same JSON, already selected, so
4997
+ // the one thing left to do is press the copy shortcut.
4998
+ exportText !== ""
4999
+ ? h("textarea", {
5000
+ className: "dfp-search dfp-exportText",
5001
+ rows: 3,
5002
+ readOnly: true,
5003
+ autoFocus: true,
5004
+ spellCheck: false,
5005
+ "aria-label": t("preset.exportManual"),
5006
+ value: exportText,
5007
+ onFocus: function (event) {
5008
+ event.target.select();
5009
+ },
5010
+ onCopy: function () {
5011
+ setExportText("");
5012
+ },
5013
+ })
4855
5014
  : null
4856
5015
  ),
4857
5016
 
@@ -5075,6 +5234,11 @@ function FontCard(props) {
5075
5234
  for (var index = 0; index < VALUE_FIELDS.length; index += 1) {
5076
5235
  clear(VALUE_FIELDS[index]);
5077
5236
  }
5237
+ // The retired axes are cleared too: they render nothing, so a
5238
+ // value left behind would be invisible forever.
5239
+ for (var retired = 0; retired < RETIRED_FIELDS.length; retired += 1) {
5240
+ clear(RETIRED_FIELDS[retired]);
5241
+ }
5078
5242
  clear(DARK_VALUES_FIELD);
5079
5243
  // Auto-save: the reset IS a change to the active preset, so
5080
5244
  // its snapshot follows back to the neutral defaults.
@@ -5191,7 +5355,38 @@ function apply(ctx) {
5191
5355
  function () {
5192
5356
  if (typeof document === "undefined") return undefined;
5193
5357
  var lastSignature = "";
5194
- var recheck = function () {
5358
+ var lastGate = null;
5359
+ var ticks = 0;
5360
+ // Reading the base tokens walks every property of every rule of every
5361
+ // stylesheet — far too much to repeat every four seconds forever. This
5362
+ // gate covers everything that moves those tokens in DSH: the theme
5363
+ // writes its content font size inline on `body`, a light/dark switch
5364
+ // swaps the class on `html`, and a lazily loaded view adds a stylesheet.
5365
+ // A full sweep still runs every Nth tick, which is what catches an
5366
+ // in-place rewrite inside the CSS-in-JS sheet.
5367
+ var GATE_TICKS_PER_SWEEP = 8;
5368
+ var gate = function () {
5369
+ var inline = "";
5370
+ var root = null;
5371
+ try {
5372
+ inline = document.body ? document.body.getAttribute("style") || "" : "";
5373
+ root = document.documentElement;
5374
+ } catch (error) {
5375
+ inline = "";
5376
+ }
5377
+ return (
5378
+ inline +
5379
+ "|" +
5380
+ (root ? root.className || "" : "") +
5381
+ "|" +
5382
+ document.styleSheets.length
5383
+ );
5384
+ };
5385
+ var recheck = function (force) {
5386
+ ticks += 1;
5387
+ var current = gate();
5388
+ if (force !== true && ticks % GATE_TICKS_PER_SWEEP !== 0 && current === lastGate) return;
5389
+ lastGate = current;
5195
5390
  var next = readBaseTokens();
5196
5391
  var signature = "";
5197
5392
  var name;
@@ -5208,7 +5403,9 @@ function apply(ctx) {
5208
5403
  sync();
5209
5404
  };
5210
5405
  scheduleRecheck = recheck;
5211
- var timer = globalThis.setTimeout(recheck, 500);
5406
+ var timer = globalThis.setTimeout(function () {
5407
+ recheck(true);
5408
+ }, 500);
5212
5409
  var interval = globalThis.setInterval(recheck, 4000);
5213
5410
  return function () {
5214
5411
  globalThis.clearTimeout(timer);
@@ -5223,7 +5420,8 @@ function apply(ctx) {
5223
5420
  // this hook; the polling recheck still covers theme changes there.
5224
5421
  if (typeof ctx.on !== "function") return undefined;
5225
5422
  return ctx.on("theme/change", function () {
5226
- if (scheduleRecheck !== null) scheduleRecheck();
5423
+ // Forced: the theme may have moved without touching the cheap gate.
5424
+ if (scheduleRecheck !== null) scheduleRecheck(true);
5227
5425
  });
5228
5426
  },
5229
5427
  "dsh-fonttune: theme change adoption"